@m13v/s4l 1.7.6-rc.21 → 1.7.6-rc.23
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/package.json +1 -1
- package/package.json +1 -1
- package/scripts/dm_conversation.py +8 -6
- package/scripts/draft_prompt_core.py +6 -2
- package/scripts/engage_reddit.py +5 -1
- package/scripts/engagement_signal_feedback.py +1 -4
- package/scripts/feedback_digest.py +27 -3
- package/scripts/learned_preferences.py +31 -40
- package/scripts/post_reddit.py +4 -1
- package/scripts/reddit_browser.py +3 -1
- package/scripts/reddit_chat_sync.py +3 -1
- package/scripts/reddit_tools.py +1 -1
- package/scripts/test_no_silent_fallbacks.py +73 -0
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.6-rc.
|
|
5
|
+
"version": "1.7.6-rc.23",
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.6-rc.
|
|
3
|
+
"version": "1.7.6-rc.23",
|
|
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
|
@@ -85,15 +85,17 @@ def load_config():
|
|
|
85
85
|
|
|
86
86
|
def get_our_account(config, platform):
|
|
87
87
|
accounts = config.get("accounts", {})
|
|
88
|
+
# No hardcoded fallback on any platform: a default handle/username/name stamped
|
|
89
|
+
# on a DM silently mis-attributes it to the repo owner. Resolve from config / env
|
|
90
|
+
# through the one resolver; "" means unknown (caller decides how to degrade).
|
|
91
|
+
from account_resolver import resolve as _resolve_account
|
|
88
92
|
if platform == "reddit":
|
|
89
|
-
return accounts.get("reddit", {}).get("username"
|
|
93
|
+
return accounts.get("reddit", {}).get("username") or _resolve_account("reddit") or ""
|
|
90
94
|
elif platform == "linkedin":
|
|
91
|
-
return accounts.get("linkedin", {}).get("name"
|
|
95
|
+
return accounts.get("linkedin", {}).get("name") or _resolve_account("linkedin") or ""
|
|
92
96
|
elif platform == "x":
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
# and fail loud if absent.
|
|
96
|
-
from account_resolver import resolve as _resolve_account
|
|
97
|
+
# Twitter fails LOUD if unresolved (an outbound DM must carry a real
|
|
98
|
+
# sender); reddit/linkedin above degrade to "" since their callers can.
|
|
97
99
|
h = _resolve_account("twitter")
|
|
98
100
|
if not h:
|
|
99
101
|
raise RuntimeError(
|
|
@@ -151,9 +151,10 @@ def _treatment_core(voice_examples_ref):
|
|
|
151
151
|
"""
|
|
152
152
|
return (
|
|
153
153
|
"PRIORITY ORDER for how you write this, highest first: (1) "
|
|
154
|
-
"learned_preferences.draft_style_notes
|
|
154
|
+
"learned_preferences.draft_style_notes are the "
|
|
155
155
|
"strongest signal available, real human corrections to this "
|
|
156
|
-
"account's own past drafts
|
|
156
|
+
"account's own past drafts (some carry a before/after example), and "
|
|
157
|
+
"are MANDATORY, not advisory. (2) "
|
|
157
158
|
f"The ACCOUNT VOICE CORPUS block and {voice_examples_ref} are "
|
|
158
159
|
"VERBATIM GROUND TRUTH for how this account actually writes: "
|
|
159
160
|
"capitalization, punctuation, contractions, sentence length, "
|
|
@@ -332,6 +333,9 @@ def global_learned_prefs_json():
|
|
|
332
333
|
config = _load_config()
|
|
333
334
|
block = lp.get_global_block(config)
|
|
334
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)
|
|
335
339
|
return json.dumps(block, indent=2)
|
|
336
340
|
except Exception:
|
|
337
341
|
return "{}"
|
package/scripts/engage_reddit.py
CHANGED
|
@@ -386,7 +386,11 @@ def get_recent_archetypes(platform, limit=3):
|
|
|
386
386
|
|
|
387
387
|
def build_prompt(reply, recent_replies, config, excluded_authors, top_report="", prior_history_block="", meta_callout=None):
|
|
388
388
|
"""Build a minimal prompt for one reply."""
|
|
389
|
-
|
|
389
|
+
# Resolve through the one account resolver (env -> config); never a hardcoded
|
|
390
|
+
# username. Empty means "unknown account" (the prompt just omits it) rather
|
|
391
|
+
# than silently impersonating the repo owner on a misconfigured install.
|
|
392
|
+
from account_resolver import resolve as _resolve_account
|
|
393
|
+
reddit_username = _resolve_account("reddit") or ""
|
|
390
394
|
reply_json = json.dumps(reply, indent=2)
|
|
391
395
|
|
|
392
396
|
# 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
|
|
|
@@ -277,6 +277,30 @@ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] |
|
|
|
277
277
|
f"{plat_lines}"
|
|
278
278
|
)
|
|
279
279
|
|
|
280
|
+
ex_pairs = block.get("edit_examples") or []
|
|
281
|
+
if ex_pairs:
|
|
282
|
+
ex_lines = "\n".join(
|
|
283
|
+
f"{i + 1}. ORIGINAL (ours): {e.get('original', '')}\n"
|
|
284
|
+
f" FINAL (user rewrote to): {e.get('final', '')}"
|
|
285
|
+
for i, e in enumerate(ex_pairs)
|
|
286
|
+
)
|
|
287
|
+
edit_examples_block = (
|
|
288
|
+
f"\n\nACCUMULATED EDIT EXAMPLES ({len(ex_pairs)} total, newest "
|
|
289
|
+
"first): each is a draft the user hand-rewrote on the review card "
|
|
290
|
+
"before approving it, so every FINAL is a direct statement of the "
|
|
291
|
+
"voice they want. The drafting model NO LONGER sees these pairs "
|
|
292
|
+
"directly, so the ONLY way a rewrite's lesson reaches drafting is "
|
|
293
|
+
"if you distill it into a draft_style_notes entry. When 2+ examples "
|
|
294
|
+
"share a correction (a phrase type removed, a structure replaced, "
|
|
295
|
+
"tone or length shifted), write ONE draft_style_notes entry and "
|
|
296
|
+
"embed a short before/after fragment as an anchor (e.g. rewrote "
|
|
297
|
+
"'X' to 'Y') so the concrete rewrite travels with the rule. Ignore "
|
|
298
|
+
"lead-specific or cosmetic edits (typo fixes, one-off facts); learn "
|
|
299
|
+
f"only what generalizes across products.\n{ex_lines}"
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
edit_examples_block = ""
|
|
303
|
+
|
|
280
304
|
return f"""You maintain the SINGLE, install-wide learned_preferences block for this social-posting pipeline (shared across every configured project, not just "{project.get('name')}"). It distills the user's own approve/reject decisions on draft cards into short standing preferences that steer future thread selection and drafting across ALL projects. It is SOFT guidance read by the drafting model, not a filter.
|
|
281
305
|
|
|
282
306
|
The events below all happened to come from project "{project.get('name')}"'s cards, but since the block is shared, only propose an entry that generalizes to a human reviewer's standing taste or quality bar (voice, tone, structural habits, author-quality signals, what counts as a good vs bad reply) — NOT something that is true only because of this one product's specific audience, niche, or content angle (e.g. "prefers accounts studying for nursing boards" belongs to one product's ICP, not to every project this reviewer runs). When the evidence is really project-specific, prefer NO change over forcing it into the shared block.
|
|
@@ -285,7 +309,7 @@ CURRENT learned_preferences (shared by every project):
|
|
|
285
309
|
{json.dumps({k: block[k] for k in ("audience_avoid", "audience_prefer", "thread_avoid", "draft_style_notes")}, indent=2)}
|
|
286
310
|
|
|
287
311
|
CURRENT voice.never: {json.dumps(voice_never)}
|
|
288
|
-
CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
|
|
312
|
+
CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}{edit_examples_block}
|
|
289
313
|
|
|
290
314
|
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, {len(plat_removed)} platform moderation strikes):
|
|
291
315
|
{ev_lines}{overall_block}{platform_block}
|
|
@@ -304,8 +328,8 @@ Propose changes to the block. RULES, in priority order:
|
|
|
304
328
|
3. Describe author/audience TYPES, never individual handles. "crypto/web3-native accounts shilling tokens" is right; "@someguy" is wrong. Preferences must generalize.
|
|
305
329
|
4. Approvals are counter-evidence. If approvals contradict an existing entry, propose removing or narrowing it. Also propose removing entries that events show are stale.
|
|
306
330
|
5. bad_draft events feed draft_style_notes (or, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
|
|
307
|
-
6.
|
|
308
|
-
7.
|
|
331
|
+
6. Keep each entry plain language, no em dashes, no hashtags, understandable a month from now without these events. There is NO length or per-list count cap. A draft_style_notes entry SHOULD embed a short before/after fragment from the edit examples when one fits (e.g. rewrote 'X' to 'Y'); that anchor is the only way the concrete rewrite reaches the drafting model. audience_avoid, audience_prefer, and thread_avoid stay short type descriptions with no example.
|
|
332
|
+
7. Never propose a note that tells the writer to drop, strip, omit, or leave out the product link or url, or to remove trailing punctuation. Those contradict the tail-link bridge feature and are auto-rejected on write, so spend no entry on them even if an edit example shows the user deleting a link.
|
|
309
333
|
8. Scope check (this block is shared across every project): before proposing an entry, ask whether it would still make sense read from a totally different product's cards. If it only makes sense for "{project.get('name')}" specifically, don't add it.
|
|
310
334
|
|
|
311
335
|
OUTPUT: a single JSON object, nothing else. Schema:
|
|
@@ -68,20 +68,17 @@ MANAGED_LISTS = ("audience_avoid", "audience_prefer", "thread_avoid", "draft_sty
|
|
|
68
68
|
# Existing config fields the digest may APPEND to (never remove from).
|
|
69
69
|
APPEND_ONLY_FIELDS = ("voice_never_add", "guardrails_do_not_add")
|
|
70
70
|
|
|
71
|
-
MAX_ENTRIES_PER_LIST = 10
|
|
72
|
-
MAX_ENTRY_CHARS = 200
|
|
73
71
|
MAX_HISTORY = 50
|
|
74
72
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
MAX_EDIT_EXAMPLES =
|
|
84
|
-
MAX_EXAMPLE_CHARS = 600
|
|
73
|
+
# Edit examples: (original, final) pairs from the user's own card rewrites,
|
|
74
|
+
# recorded DETERMINISTICALLY by feedback_digest via record_edit_examples()
|
|
75
|
+
# (never by the digest LLM's mutation plan). As of 2026-07-17 these are read
|
|
76
|
+
# ONLY by the feedback digest, which distills recurring corrections into
|
|
77
|
+
# draft_style_notes (optionally embedding a short before/after fragment as an
|
|
78
|
+
# anchor); the drafting/judging prompts NO LONGER embed them. Kept newest-first
|
|
79
|
+
# and capped at MAX_EDIT_EXAMPLES so the digest sees a deep rewrite history.
|
|
80
|
+
# No per-entry length cap: a rewrite is the user's exact words, stored verbatim.
|
|
81
|
+
MAX_EDIT_EXAMPLES = 100
|
|
85
82
|
|
|
86
83
|
# Travels inside the global block the prep prompt embeds (once, via
|
|
87
84
|
# GLOBAL_LEARNED_PREFS_JSON), so the drafting model reads its own operating
|
|
@@ -100,12 +97,9 @@ DEFAULT_INSTRUCTION = (
|
|
|
100
97
|
"exceptionally on-topic despite a match. When WRITING a draft, "
|
|
101
98
|
"draft_style_notes is MANDATORY, not advisory: follow every entry, and "
|
|
102
99
|
"on conflict it overrides the engagement style's structural template. "
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"target voice and every 'original' as the rejected voice: write new "
|
|
107
|
-
"drafts in the style of the finals, and never reproduce phrasing or "
|
|
108
|
-
"structure that the user edited away."
|
|
100
|
+
"Some draft_style_notes carry a short before/after example of a real "
|
|
101
|
+
"user rewrite; match the 'after' phrasing and never reproduce the "
|
|
102
|
+
"'before'."
|
|
109
103
|
)
|
|
110
104
|
|
|
111
105
|
|
|
@@ -156,15 +150,15 @@ def normalized(block) -> dict:
|
|
|
156
150
|
}
|
|
157
151
|
for key in MANAGED_LISTS:
|
|
158
152
|
vals = b.get(key)
|
|
159
|
-
out[key] = [str(v).strip()
|
|
153
|
+
out[key] = [str(v).strip() for v in vals if str(v).strip()] if isinstance(vals, list) else []
|
|
160
154
|
# Few-shot before/after pairs from the user's card edits (newest first).
|
|
161
155
|
# Preserved through normalization so apply_mutations round-trips never
|
|
162
156
|
# drop them; only record_edit_examples() writes this list.
|
|
163
157
|
ex = b.get("edit_examples")
|
|
164
158
|
out["edit_examples"] = [
|
|
165
159
|
{
|
|
166
|
-
"original": str(e.get("original") or "")
|
|
167
|
-
"final": str(e.get("final") or "")
|
|
160
|
+
"original": str(e.get("original") or ""),
|
|
161
|
+
"final": str(e.get("final") or ""),
|
|
168
162
|
"ts": e.get("ts"),
|
|
169
163
|
}
|
|
170
164
|
for e in (ex if isinstance(ex, list) else [])
|
|
@@ -223,12 +217,8 @@ def prompt_block(project_cfg=None) -> str:
|
|
|
223
217
|
for key in MANAGED_LISTS:
|
|
224
218
|
for v in b[key]:
|
|
225
219
|
lines.append(f"- {labels[key]}: {v}")
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
"- Edit example (write like FINAL, never like ORIGINAL):\n"
|
|
229
|
-
f" ORIGINAL (ours, rejected style): {e['original']}\n"
|
|
230
|
-
f" FINAL (user's rewrite, target style): {e['final']}"
|
|
231
|
-
)
|
|
220
|
+
# edit_examples are intentionally NOT rendered here (2026-07-17): they feed
|
|
221
|
+
# only the feedback digest now, which distills them into draft_style_notes.
|
|
232
222
|
if not lines:
|
|
233
223
|
return ""
|
|
234
224
|
return (
|
|
@@ -240,15 +230,15 @@ def prompt_block(project_cfg=None) -> str:
|
|
|
240
230
|
)
|
|
241
231
|
|
|
242
232
|
|
|
243
|
-
def _validate_add_list(raw, cap=
|
|
233
|
+
def _validate_add_list(raw, cap=None):
|
|
244
234
|
out = []
|
|
245
235
|
if not isinstance(raw, list):
|
|
246
236
|
return out
|
|
247
237
|
for v in raw:
|
|
248
238
|
s = str(v).strip()
|
|
249
239
|
if s:
|
|
250
|
-
out.append(s
|
|
251
|
-
if len(out) >= cap:
|
|
240
|
+
out.append(s)
|
|
241
|
+
if cap is not None and len(out) >= cap:
|
|
252
242
|
break
|
|
253
243
|
return out
|
|
254
244
|
|
|
@@ -279,8 +269,8 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
|
|
|
279
269
|
if not orig or not final or orig == final:
|
|
280
270
|
continue
|
|
281
271
|
clean.append({
|
|
282
|
-
"original": orig
|
|
283
|
-
"final": final
|
|
272
|
+
"original": orig,
|
|
273
|
+
"final": final,
|
|
284
274
|
"ts": str(p.get("ts") or _now_iso()),
|
|
285
275
|
})
|
|
286
276
|
if not clean:
|
|
@@ -390,9 +380,15 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
|
|
|
390
380
|
for v in _validate_add_list(ops.get("add")):
|
|
391
381
|
if v in block[key]:
|
|
392
382
|
continue
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
383
|
+
# Never let the digest (re)learn a link/punctuation suppression
|
|
384
|
+
# note: it contradicts the tail-link bridge feature. Same guard
|
|
385
|
+
# migrate_to_global uses, now enforced on every digest write so
|
|
386
|
+
# reading the edit_examples pool can't resurrect URL-stripping.
|
|
387
|
+
if key == "draft_style_notes":
|
|
388
|
+
reason = _excluded_note_reason(v)
|
|
389
|
+
if reason:
|
|
390
|
+
dropped.append(f"{key} rejected ({reason}): {v}")
|
|
391
|
+
continue
|
|
396
392
|
block[key].append(v)
|
|
397
393
|
applied.append(f"{key} added: {v}")
|
|
398
394
|
|
|
@@ -528,9 +524,6 @@ def migrate_to_global(cfg_path: str | None = None) -> dict:
|
|
|
528
524
|
continue
|
|
529
525
|
if v in global_block[key]:
|
|
530
526
|
continue
|
|
531
|
-
if len(global_block[key]) >= MAX_ENTRIES_PER_LIST:
|
|
532
|
-
dropped_at_cap.append({"project": pname, "key": key, "value": v})
|
|
533
|
-
continue
|
|
534
527
|
global_block[key].append(v)
|
|
535
528
|
touched = True
|
|
536
529
|
existing_finals = {e["final"] for e in global_block["edit_examples"]}
|
|
@@ -552,8 +545,6 @@ def migrate_to_global(cfg_path: str | None = None) -> dict:
|
|
|
552
545
|
summary = f"migrated {len(projects_merged)} project block(s) into learned_preferences_global"
|
|
553
546
|
if excluded:
|
|
554
547
|
summary += f"; excluded {len(excluded)} note(s) (link/punctuation suppression)"
|
|
555
|
-
if dropped_at_cap:
|
|
556
|
-
summary += f"; {len(dropped_at_cap)} entrie(s) dropped at the {MAX_ENTRIES_PER_LIST}-per-list cap"
|
|
557
548
|
merged_history.append({
|
|
558
549
|
"ts": _now_iso(),
|
|
559
550
|
"change": summary,
|
package/scripts/post_reddit.py
CHANGED
|
@@ -2878,7 +2878,10 @@ def main():
|
|
|
2878
2878
|
args = parser.parse_args()
|
|
2879
2879
|
|
|
2880
2880
|
config = load_config()
|
|
2881
|
-
|
|
2881
|
+
# Resolve through the one account resolver (env -> config); never a hardcoded
|
|
2882
|
+
# username. Empty = "unknown account" rather than impersonating the repo owner.
|
|
2883
|
+
from account_resolver import resolve as _resolve_account
|
|
2884
|
+
reddit_username = _resolve_account("reddit") or ""
|
|
2882
2885
|
|
|
2883
2886
|
if args.phase == "phase0":
|
|
2884
2887
|
# Hard-expire stale pending rows + re-assign salvageable rows to the
|
|
@@ -91,7 +91,9 @@ VIEWPORT = {"width": 911, "height": 1016}
|
|
|
91
91
|
# matching comments → permalink=None → pipeline records `failed` despite
|
|
92
92
|
# the comment landing on Reddit).
|
|
93
93
|
_config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json")
|
|
94
|
-
|
|
94
|
+
# No hardcoded username: "" means "unknown account". A wrong default here silently
|
|
95
|
+
# mis-attributes / breaks the own-comment permalink lookup on a misconfigured install.
|
|
96
|
+
OUR_USERNAME = ""
|
|
95
97
|
if os.path.exists(_config_path):
|
|
96
98
|
try:
|
|
97
99
|
with open(_config_path) as f:
|
|
@@ -48,7 +48,9 @@ USER_AGENT = (
|
|
|
48
48
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
49
49
|
)
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
# No hardcoded username: "" means "unknown account" rather than impersonating the
|
|
52
|
+
# repo owner when config.json carries no reddit account.
|
|
53
|
+
OUR_USERNAME = ""
|
|
52
54
|
_config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json")
|
|
53
55
|
if os.path.exists(_config_path):
|
|
54
56
|
try:
|
package/scripts/reddit_tools.py
CHANGED
|
@@ -912,7 +912,7 @@ def main():
|
|
|
912
912
|
p_log.add_argument("project")
|
|
913
913
|
p_log.add_argument("thread_author")
|
|
914
914
|
p_log.add_argument("thread_title")
|
|
915
|
-
p_log.add_argument("--account", default="
|
|
915
|
+
p_log.add_argument("--account", default=(_resolve_account("reddit") or ""))
|
|
916
916
|
p_log.add_argument("--engagement-style", default=None)
|
|
917
917
|
p_log.add_argument("--search-topic", dest="search_topic", default=None,
|
|
918
918
|
help="The seed topic/query used to find this thread (feedback loop input)")
|
|
@@ -24,6 +24,22 @@ Two checks:
|
|
|
24
24
|
bug_twitter_handle_scrape_brittle_m13v_fallback.md and
|
|
25
25
|
bug_multitenant_no_install_scoping_account_fallback.md).
|
|
26
26
|
|
|
27
|
+
3. check_style_assignment_render_agrees_with_pick() -- an engagement-style
|
|
28
|
+
picker (engagement_styles.pick_style_for_post/pick_exploration_style)
|
|
29
|
+
and the prompt renderer (get_assigned_style_prompt) must never disagree
|
|
30
|
+
about what was assigned, on any platform, under any draft-prompt
|
|
31
|
+
experiment arm; whatever a picker assigns must also survive
|
|
32
|
+
validate_or_register() unmodified. Deliberately asserts nothing about
|
|
33
|
+
what any specific arm's CONTENT is (arms are experiments and will keep
|
|
34
|
+
changing) -- only that the picker and the renderer agree with each
|
|
35
|
+
other for whatever they currently do. Guards against the class of bug
|
|
36
|
+
where a special-case decision lives in only one of the two functions:
|
|
37
|
+
one platform's driver hand-patches around the gap, the next platform's
|
|
38
|
+
driver (calling the picker directly) doesn't, and a real post lands
|
|
39
|
+
with the wrong engagement_style, silently coerced by
|
|
40
|
+
validate_or_register's drift protection (2026-07-17, see commit
|
|
41
|
+
2723662d and the Reddit r/saasbuild incident it fixed).
|
|
42
|
+
|
|
27
43
|
Run:
|
|
28
44
|
python3 scripts/test_no_silent_fallbacks.py
|
|
29
45
|
Exit 0 = all pass; non-zero with FAIL lines otherwise.
|
|
@@ -273,11 +289,68 @@ def check_account_resolver_hard_fails():
|
|
|
273
289
|
os.environ.update(saved_env)
|
|
274
290
|
|
|
275
291
|
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# Check 3: engagement-style picker and prompt renderer must never disagree
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def check_style_assignment_render_agrees_with_pick():
|
|
297
|
+
sys.path.insert(0, os.path.join(REPO_ROOT, "scripts"))
|
|
298
|
+
import engagement_styles as es
|
|
299
|
+
import draft_prompt_core as dpc
|
|
300
|
+
|
|
301
|
+
saved_env = os.environ.get("S4L_DRAFT_PROMPT_VARIANT")
|
|
302
|
+
try:
|
|
303
|
+
# dpc.ARM_TREATMENT is whatever the CURRENT draft-prompt experiment's
|
|
304
|
+
# specially-handled arm is called -- read from its one source of
|
|
305
|
+
# truth, never duplicated as a literal here, so a future rename
|
|
306
|
+
# (e.g. treatment_v4 -> treatment_v5) needs no change to this test.
|
|
307
|
+
# This is also the arm that resolves without any network call in
|
|
308
|
+
# both functions under test, so this check stays fast and offline.
|
|
309
|
+
os.environ["S4L_DRAFT_PROMPT_VARIANT"] = dpc.ARM_TREATMENT
|
|
310
|
+
for platform in ("twitter", "reddit", "linkedin", "github", "moltbook"):
|
|
311
|
+
pickers = (
|
|
312
|
+
("pick_style_for_post",
|
|
313
|
+
lambda p=platform: es.pick_style_for_post(p, context="posting")),
|
|
314
|
+
("pick_exploration_style",
|
|
315
|
+
lambda p=platform: es.pick_exploration_style(p, context="posting", exclude=set())),
|
|
316
|
+
)
|
|
317
|
+
for label, picker in pickers:
|
|
318
|
+
assignment = picker()
|
|
319
|
+
if not assignment or assignment.get("mode") != "use" or not assignment.get("style"):
|
|
320
|
+
continue # nothing assigned this call -- nothing to cross-check
|
|
321
|
+
|
|
322
|
+
rendered = es.get_assigned_style_prompt(platform, assignment, context="posting")
|
|
323
|
+
check(
|
|
324
|
+
f"{platform}/{label}: render names the exact style it was handed",
|
|
325
|
+
assignment["style"] in rendered,
|
|
326
|
+
f"assigned={assignment['style']!r}",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
decision = {"engagement_style": assignment["style"], "new_style": None}
|
|
330
|
+
validated_style, action = es.validate_or_register(
|
|
331
|
+
decision,
|
|
332
|
+
assigned_style=assignment["style"],
|
|
333
|
+
assigned_mode=assignment["mode"],
|
|
334
|
+
)
|
|
335
|
+
check(
|
|
336
|
+
f"{platform}/{label}: assignment survives validate_or_register unmodified",
|
|
337
|
+
action in ("valid", "registered") and validated_style == assignment["style"],
|
|
338
|
+
f"assigned={assignment['style']!r} action={action!r} got={validated_style!r}",
|
|
339
|
+
)
|
|
340
|
+
finally:
|
|
341
|
+
if saved_env is None:
|
|
342
|
+
os.environ.pop("S4L_DRAFT_PROMPT_VARIANT", None)
|
|
343
|
+
else:
|
|
344
|
+
os.environ["S4L_DRAFT_PROMPT_VARIANT"] = saved_env
|
|
345
|
+
|
|
346
|
+
|
|
276
347
|
def main():
|
|
277
348
|
print("-- check 1: no bare-python3 subprocess spawning a playwright-dependent script --")
|
|
278
349
|
check_no_bare_playwright_subprocess()
|
|
279
350
|
print("-- check 2: account_resolver hard-fails instead of impersonating --")
|
|
280
351
|
check_account_resolver_hard_fails()
|
|
352
|
+
print("-- check 3: engagement-style picker and renderer agree, on every platform --")
|
|
353
|
+
check_style_assignment_render_agrees_with_pick()
|
|
281
354
|
|
|
282
355
|
if FAILS:
|
|
283
356
|
print(f"\n{len(FAILS)} FAILURE(S):")
|