@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
|
@@ -1048,6 +1048,51 @@ def compute_target_distribution(platform, context="posting"):
|
|
|
1048
1048
|
INVENT_RATE = 0.0 # retired inline roll (was 0.05, ~1 in 20 posts)
|
|
1049
1049
|
CURATED_TOP_N = 5 # size of the invent-mode reference list (top 5 by score)
|
|
1050
1050
|
|
|
1051
|
+
# Draft-prompt treatment_v4 assigns NO real engagement style, on ANY platform
|
|
1052
|
+
# that participates in the S4L_DRAFT_PROMPT_VARIANT experiment (currently
|
|
1053
|
+
# Twitter and Reddit, both of which export it via
|
|
1054
|
+
# draft_prompt_core.pick_draft_prompt_arm() before picking a style). This is
|
|
1055
|
+
# the ONE place that decision is made: pick_style_for_post() and
|
|
1056
|
+
# pick_exploration_style() below both short-circuit on it, so every caller
|
|
1057
|
+
# (Twitter's s4l_pick_style bash wrapper, Reddit's direct Python call, and
|
|
1058
|
+
# any future platform driver) gets the same behavior for free, with nothing
|
|
1059
|
+
# to remember to wire up per-platform. A caller that reads the env var
|
|
1060
|
+
# directly instead of routing through these functions is the only way to
|
|
1061
|
+
# miss this. Must match draft_prompt_core.py's ARM_TREATMENT = "treatment_v4".
|
|
1062
|
+
# 2026-07-17: consolidated here after a real bug -- the same "treatment_v4 =
|
|
1063
|
+
# no style" check used to live only in run-twitter-cycle.sh (Twitter's bash
|
|
1064
|
+
# driver), one layer above this shared function, so Reddit (which calls
|
|
1065
|
+
# pick_style_for_post() directly, bypassing that bash script entirely) never
|
|
1066
|
+
# got it: Reddit kept assigning a real style, the model still correctly
|
|
1067
|
+
# wrote "voice_first" per get_assigned_style_prompt()'s render-side check
|
|
1068
|
+
# below, and validate_or_register()'s USE-mode drift protection silently
|
|
1069
|
+
# coerced "voice_first" back to the real assigned style at post time.
|
|
1070
|
+
STYLE_SENTINEL_TREATMENT = "voice_first"
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _is_treatment_v4():
|
|
1074
|
+
return (os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or "").strip() == "treatment_v4"
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _treatment_v4_assignment():
|
|
1078
|
+
"""The single no-style assignment returned by both pickers below when
|
|
1079
|
+
_is_treatment_v4(). style is the literal sentinel, not None/empty, on
|
|
1080
|
+
purpose: this makes the assignment identical to what the model is told
|
|
1081
|
+
to output (see get_assigned_style_prompt), so validate_or_register's
|
|
1082
|
+
first, simplest check (style == assigned_style -> valid) accepts it
|
|
1083
|
+
with no special-casing anywhere downstream, on either platform."""
|
|
1084
|
+
return {
|
|
1085
|
+
"mode": "use",
|
|
1086
|
+
"style": STYLE_SENTINEL_TREATMENT,
|
|
1087
|
+
"description": None,
|
|
1088
|
+
"example": None,
|
|
1089
|
+
"note": None,
|
|
1090
|
+
"target_chars": None,
|
|
1091
|
+
"reference_styles": [],
|
|
1092
|
+
"distribution_snapshot": [],
|
|
1093
|
+
"picked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1051
1096
|
# Fallback target comment length (chars) for any style that lacks an explicit
|
|
1052
1097
|
# target_chars (legacy DB rows, cold-start before the registry is reachable).
|
|
1053
1098
|
# Set just above the top-human-reply median (~74) so the long tail of styles
|
|
@@ -1207,6 +1252,9 @@ def pick_style_for_post(platform, context="posting",
|
|
|
1207
1252
|
"picked_at": ISO-8601 UTC,
|
|
1208
1253
|
}
|
|
1209
1254
|
"""
|
|
1255
|
+
if _is_treatment_v4():
|
|
1256
|
+
return _treatment_v4_assignment()
|
|
1257
|
+
|
|
1210
1258
|
rnd = rng or random
|
|
1211
1259
|
|
|
1212
1260
|
# Human-derived branch (2026-05-22, second pass): on every platform,
|
|
@@ -1377,6 +1425,12 @@ def pick_exploration_style(platform, context="posting", exclude=None,
|
|
|
1377
1425
|
posted draft's engagement write the style's first real score, and
|
|
1378
1426
|
winners graduate into the Draft-A pool through the normal sampler.
|
|
1379
1427
|
"""
|
|
1428
|
+
if _is_treatment_v4():
|
|
1429
|
+
# No exploration under treatment_v4 (see STYLE_SENTINEL_TREATMENT
|
|
1430
|
+
# above): return None so the caller's existing "pool empty, fall
|
|
1431
|
+
# back to the scored picker" path fires, which routes through
|
|
1432
|
+
# pick_style_for_post() and converges on the same sentinel.
|
|
1433
|
+
return None
|
|
1380
1434
|
rnd = rng or random
|
|
1381
1435
|
try:
|
|
1382
1436
|
never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
|
|
@@ -1463,112 +1517,95 @@ def get_assigned_style_prompt(platform, assignment, context="posting"):
|
|
|
1463
1517
|
policy = PLATFORM_POLICY.get(platform, PLATFORM_POLICY["reddit"])
|
|
1464
1518
|
lines = []
|
|
1465
1519
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1520
|
+
# Draft-prompt A/B v4 (voice-first, 2026-07-15 RESET of v3; style
|
|
1521
|
+
# assignment dropped entirely 2026-07-17): v3's treatment rendered the
|
|
1522
|
+
# style as the BINDING FORM of the draft. v4 first demoted it to "an
|
|
1523
|
+
# optional idea for angle/length", but a real 21-pair batch comparison
|
|
1524
|
+
# showed the demoted style block was STILL polluting context: the
|
|
1525
|
+
# picker's one canonical example for a batch's assigned style bled its
|
|
1526
|
+
# own vocabulary into every candidate that batch regardless of topic
|
|
1527
|
+
# (e.g. a batch drawing 'attention_is_the_input' produced 6/6 drafts
|
|
1528
|
+
# pivoting on "who's paying attention", across unrelated threads). Style
|
|
1529
|
+
# is now not shown to the model AT ALL for treatment_v4: the account's
|
|
1530
|
+
# real voice corpus + learned_preferences are the sole drafting signal.
|
|
1531
|
+
# The arm is read from the env HERE because this block is rendered
|
|
1532
|
+
# inside the cycle process where run-twitter-cycle.sh assigns and
|
|
1533
|
+
# exports S4L_DRAFT_PROMPT_VARIANT (stamp-at-source: that same process
|
|
1534
|
+
# also stamps the arm onto every plan candidate via active_experiments).
|
|
1535
|
+
# Unset or control_v4 (and every non-twitter caller, which never has the
|
|
1536
|
+
# env) renders the legacy block below unchanged — that block IS the v4
|
|
1537
|
+
# control arm, same as it was the v3 control arm.
|
|
1538
|
+
#
|
|
1539
|
+
# pick_style_for_post()/pick_exploration_style() (above, in this same
|
|
1540
|
+
# module) already refuse to assign a real style under treatment_v4 and
|
|
1541
|
+
# return STYLE_SENTINEL_TREATMENT itself as `assignment["style"]`, so
|
|
1542
|
+
# this render-side check and that pick-side check agree by construction
|
|
1543
|
+
# instead of by convention -- no separate literal to keep in sync here.
|
|
1544
|
+
if _is_treatment_v4():
|
|
1545
|
+
lines.append(
|
|
1546
|
+
"## Engagement style: not used for this draft (voice-first mode)"
|
|
1547
|
+
)
|
|
1548
|
+
lines.append("")
|
|
1549
|
+
lines.append(
|
|
1550
|
+
"This account's real voice (the ACCOUNT VOICE CORPUS block and "
|
|
1551
|
+
"voice.examples) and learned_preferences drive this reply, not a "
|
|
1552
|
+
"named engagement style — no style name, description, example, "
|
|
1553
|
+
"or length target is assigned this draft. "
|
|
1554
|
+
f'In your output JSON, set "engagement_style" to exactly '
|
|
1555
|
+
f'"{STYLE_SENTINEL_TREATMENT}" and leave "new_style" as null, for BOTH '
|
|
1556
|
+
"Draft A and Draft B. Do not invent, reuse, or reference any "
|
|
1557
|
+
"other style name for this draft."
|
|
1558
|
+
)
|
|
1559
|
+
elif assignment["mode"] == "use":
|
|
1560
|
+
# This IS the v4 control arm (unchanged since v3 control / v2's control
|
|
1561
|
+
# text). treatment_v4 never reaches here; see the sentinel branch above.
|
|
1562
|
+
lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
|
|
1563
|
+
lines.append("")
|
|
1564
|
+
lines.append(
|
|
1565
|
+
f"This style was selected by the picker (weighted by live "
|
|
1566
|
+
f"click-driven performance across {platform}). Use it. Do not "
|
|
1567
|
+
f"swap it for a different listed style."
|
|
1568
|
+
)
|
|
1569
|
+
lines.append("")
|
|
1570
|
+
lines.append(f"Platform tone: {policy.get('note', '')}")
|
|
1571
|
+
lines.append("")
|
|
1572
|
+
lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
|
|
1573
|
+
if assignment.get("example"):
|
|
1574
|
+
lines.append(f' Example: "{assignment["example"]}"')
|
|
1575
|
+
if assignment.get("note"):
|
|
1576
|
+
lines.append(f" Note: {assignment['note']}")
|
|
1577
|
+
# LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
|
|
1578
|
+
# uses the legacy generic length guidance. The treatment's per-style
|
|
1579
|
+
# target prompt remains preserved only in the shipped experiment card.
|
|
1580
|
+
#
|
|
1581
|
+
# EXCEPTION (2026-07-11, Draft-B explore slot): exploration
|
|
1582
|
+
# assignments (pick_exploration_style, marked by `source`) honor the
|
|
1583
|
+
# style's own target_chars so the A/B pair diverges on the length
|
|
1584
|
+
# axis too. The uniform clamp flattened every draft to the same
|
|
1585
|
+
# 2-sentence shape (user: "the older drafts looked all very similar
|
|
1586
|
+
# in terms of the length"). The 06-04 conclusion still governs the
|
|
1587
|
+
# scored path, which never carries `source`.
|
|
1588
|
+
_explore_tc = assignment.get("target_chars")
|
|
1589
|
+
if (assignment.get("source") in ("human_derived", "model_invented")
|
|
1590
|
+
and _explore_tc):
|
|
1512
1591
|
lines.append("")
|
|
1513
1592
|
lines.append(
|
|
1514
|
-
f"**LENGTH: aim for about {int(
|
|
1593
|
+
f"**LENGTH: aim for about {int(_explore_tc)} characters** "
|
|
1515
1594
|
"(this style's own winning length; within about 30% either "
|
|
1516
|
-
"way is fine, never above 250)
|
|
1517
|
-
"
|
|
1518
|
-
"
|
|
1519
|
-
"
|
|
1595
|
+
"way is fine, never above 250). Let the target set the form: "
|
|
1596
|
+
"a very short target means one clipped line, a long one can "
|
|
1597
|
+
"breathe. Do NOT default to the usual two-sentence shape. "
|
|
1598
|
+
"This applies to the comment text only; any link/CTA the "
|
|
1599
|
+
"system appends afterward is separate."
|
|
1520
1600
|
)
|
|
1521
1601
|
else:
|
|
1522
|
-
lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
|
|
1523
1602
|
lines.append("")
|
|
1524
1603
|
lines.append(
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1604
|
+
"**LENGTH: keep it tight.** One or two sentences, well under the "
|
|
1605
|
+
"250-character Twitter limit. A short, sharp reply almost always "
|
|
1606
|
+
"beats a paragraph. This applies to the comment text only; any "
|
|
1607
|
+
"link/CTA the system appends afterward is separate."
|
|
1528
1608
|
)
|
|
1529
|
-
lines.append("")
|
|
1530
|
-
lines.append(f"Platform tone: {policy.get('note', '')}")
|
|
1531
|
-
lines.append("")
|
|
1532
|
-
lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
|
|
1533
|
-
if assignment.get("example"):
|
|
1534
|
-
lines.append(f' Example: "{assignment["example"]}"')
|
|
1535
|
-
if assignment.get("note"):
|
|
1536
|
-
lines.append(f" Note: {assignment['note']}")
|
|
1537
|
-
# LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
|
|
1538
|
-
# uses the legacy generic length guidance. The treatment's per-style
|
|
1539
|
-
# target prompt remains preserved only in the shipped experiment card.
|
|
1540
|
-
#
|
|
1541
|
-
# EXCEPTION (2026-07-11, Draft-B explore slot): exploration
|
|
1542
|
-
# assignments (pick_exploration_style, marked by `source`) honor the
|
|
1543
|
-
# style's own target_chars so the A/B pair diverges on the length
|
|
1544
|
-
# axis too. The uniform clamp flattened every draft to the same
|
|
1545
|
-
# 2-sentence shape (user: "the older drafts looked all very similar
|
|
1546
|
-
# in terms of the length"). The 06-04 conclusion still governs the
|
|
1547
|
-
# scored path, which never carries `source`.
|
|
1548
|
-
#
|
|
1549
|
-
# (The draft-prompt v3 treatment arm above supersedes this clamp
|
|
1550
|
-
# for its cycles; this legacy branch IS the v3 control arm.)
|
|
1551
|
-
_explore_tc = assignment.get("target_chars")
|
|
1552
|
-
if (assignment.get("source") in ("human_derived", "model_invented")
|
|
1553
|
-
and _explore_tc):
|
|
1554
|
-
lines.append("")
|
|
1555
|
-
lines.append(
|
|
1556
|
-
f"**LENGTH: aim for about {int(_explore_tc)} characters** "
|
|
1557
|
-
"(this style's own winning length; within about 30% either "
|
|
1558
|
-
"way is fine, never above 250). Let the target set the form: "
|
|
1559
|
-
"a very short target means one clipped line, a long one can "
|
|
1560
|
-
"breathe. Do NOT default to the usual two-sentence shape. "
|
|
1561
|
-
"This applies to the comment text only; any link/CTA the "
|
|
1562
|
-
"system appends afterward is separate."
|
|
1563
|
-
)
|
|
1564
|
-
else:
|
|
1565
|
-
lines.append("")
|
|
1566
|
-
lines.append(
|
|
1567
|
-
"**LENGTH: keep it tight.** One or two sentences, well under the "
|
|
1568
|
-
"250-character Twitter limit. A short, sharp reply almost always "
|
|
1569
|
-
"beats a paragraph. This applies to the comment text only; any "
|
|
1570
|
-
"link/CTA the system appends afterward is separate."
|
|
1571
|
-
)
|
|
1572
1609
|
lines.append("")
|
|
1573
1610
|
lines.append(
|
|
1574
1611
|
'In your output JSON, set "engagement_style" to exactly '
|
|
@@ -1835,13 +1872,15 @@ def get_content_rules(platform):
|
|
|
1835
1872
|
]
|
|
1836
1873
|
|
|
1837
1874
|
platform_rules = {
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1875
|
+
# Reddit-specific rules deprecated 2026-07-18 (user decision): the
|
|
1876
|
+
# 2026-04-13 "data-driven" claims behind them (bimodal length,
|
|
1877
|
+
# statements>questions, first-person openings) no longer reproduce on
|
|
1878
|
+
# current posts data, and they blocked X/Reddit draft-prompt parity.
|
|
1879
|
+
# The reddit DRAFT prompt no longer includes content rules at all
|
|
1880
|
+
# (draft_prompt_core dropped @CONTENT_RULES@); only the engage rail
|
|
1881
|
+
# still renders this, now common-rules-only. The stale hardcoded
|
|
1882
|
+
# product-name list that lived here is gone with them.
|
|
1883
|
+
"reddit": [],
|
|
1845
1884
|
"twitter": [
|
|
1846
1885
|
"Keep it short: 1-2 sentences max. Fragments and lowercase OK.",
|
|
1847
1886
|
"Direct product mentions OK when relevant (unlike Reddit).",
|
|
@@ -83,7 +83,7 @@ import learned_preferences as lp # noqa: E402
|
|
|
83
83
|
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
84
84
|
RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
|
|
85
85
|
LOCK_PATH = os.path.expanduser("~/.social-autoposter-mcp/feedback-digest.lock")
|
|
86
|
-
MAX_EVENTS_PER_RUN =
|
|
86
|
+
MAX_EVENTS_PER_RUN = 5
|
|
87
87
|
# A cluster of reason-less rejects in one batch is a signal worth flagging
|
|
88
88
|
# even when no shared textual pattern justifies a learned_preferences entry:
|
|
89
89
|
# pure volume with no discernible pattern usually means something upstream
|
|
@@ -277,6 +277,31 @@ 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. Even a SINGLE "
|
|
294
|
+
"example with a clear correction (a phrase type removed, a "
|
|
295
|
+
"structure replaced, tone or length shifted) is enough to write a "
|
|
296
|
+
"draft_style_notes entry and "
|
|
297
|
+
"embed a short before/after fragment as an anchor (e.g. rewrote "
|
|
298
|
+
"'X' to 'Y') so the concrete rewrite travels with the rule. Ignore "
|
|
299
|
+
"lead-specific or cosmetic edits (typo fixes, one-off facts); learn "
|
|
300
|
+
f"only what generalizes across products.\n{ex_lines}"
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
edit_examples_block = ""
|
|
304
|
+
|
|
280
305
|
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
306
|
|
|
282
307
|
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,31 +310,31 @@ CURRENT learned_preferences (shared by every project):
|
|
|
285
310
|
{json.dumps({k: block[k] for k in ("audience_avoid", "audience_prefer", "thread_avoid", "draft_style_notes")}, indent=2)}
|
|
286
311
|
|
|
287
312
|
CURRENT voice.never: {json.dumps(voice_never)}
|
|
288
|
-
CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
|
|
313
|
+
CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}{edit_examples_block}
|
|
289
314
|
|
|
290
|
-
NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(
|
|
315
|
+
NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(approved)} approved, {len(loved)} of the approvals loved, {len(plat_removed)} platform moderation strikes):
|
|
291
316
|
{ev_lines}{overall_block}{platform_block}
|
|
292
317
|
|
|
293
|
-
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.
|
|
318
|
+
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. (Rejects with no category and no note are filtered out before this prompt, so every reject below carries a real reason.) "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; even a single edit that shows a clear correction (a phrase type removed, a structure replaced, tone shifted, length cut) is enough to distill into draft_style_notes, and several agreeing only make it stronger. 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.
|
|
294
319
|
|
|
295
|
-
Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat
|
|
320
|
+
Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat it as weak, corroborating a pattern that stronger events already show rather than founding one on its own). "second_draft_not_read" means they approved the default without touching or reading the alternative: NO pairwise signal, never infer anything against the unread draft. A single pairwise choice is enough: diff the picked text against the not-picked one and distill WHAT differs (tone, structure, length, opener type, directness) into draft_style_notes, and several agreeing only make it stronger; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
|
|
296
321
|
|
|
297
|
-
"[platform_removed]" and "[platform_banned]" events are MACHINE-DERIVED moderation outcomes, not human card decisions; weigh them on their own track, never as a statement of the reviewer's taste. platform_removed means a moderator or platform filter removed one of our posts: the lesson is the CONTENT pattern (what reads as promotional, off-topic, or AI-written in that kind of venue), feeding thread_avoid or draft_style_notes.
|
|
322
|
+
"[platform_removed]" and "[platform_banned]" events are MACHINE-DERIVED moderation outcomes, not human card decisions; weigh them on their own track, never as a statement of the reviewer's taste. platform_removed means a moderator or platform filter removed one of our posts: the lesson is the CONTENT pattern (what reads as promotional, off-topic, or AI-written in that kind of venue), feeding thread_avoid or draft_style_notes. A removal is a hard, machine-known fact (not an inferred taste signal), so a SINGLE removal is enough: treat it like a reasoned reject and distill the content-pattern lesson right away, since the moderation context states the venue and outcome explicitly; more removals sharing a pattern only make it stronger. platform_banned means a community blocked our account outright: the strongest platform-side evidence, but the specific venue is ALREADY hard-blocked by the pipeline, so never spend an entry naming an individual community; the only lesson worth an entry is a venue TYPE pattern (e.g. what kinds of communities keep rejecting us), and a single ban or removal is enough to name that TYPE. Platform events never justify block_authors (there is no author at fault) and never touch audience_prefer.
|
|
298
323
|
|
|
299
324
|
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.
|
|
300
325
|
|
|
301
326
|
Propose changes to the block. RULES, in priority order:
|
|
302
327
|
1. Be conservative. Prefer NO changes over speculative ones. An empty plan is a good plan when the evidence is thin.
|
|
303
|
-
2.
|
|
328
|
+
2. A SINGLE reasoned event is enough to justify an entry; you do NOT need multiple events agreeing. When a reject carries a category, a note, a profile-click, or clear author/thread context, or an approval is loved, or the user edited the draft, or they made an explicit two-draft choice, distill the lesson right away into the most fitting list, generalized to the TYPE (never an individual handle or a one-off fact). More events agreeing only make the pattern stronger; they are not a precondition. OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one even from a single line, rewritten as a standing preference.
|
|
304
329
|
3. Describe author/audience TYPES, never individual handles. "crypto/web3-native accounts shilling tokens" is right; "@someguy" is wrong. Preferences must generalize.
|
|
305
330
|
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
|
-
5. bad_draft events feed draft_style_notes (or,
|
|
307
|
-
6.
|
|
308
|
-
7.
|
|
331
|
+
5. bad_draft events feed draft_style_notes (or, for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add). voice.never and content_guardrails.do_not are the CURRENT project's curated fields, shown above; you may ADD to them and also REMOVE entries (voice_never_remove / guardrails_do_not_remove) when an approval or the user's feedback shows an entry is now wrong or stale. There is no cap. remove values must match an existing entry exactly.
|
|
332
|
+
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.
|
|
333
|
+
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
334
|
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
335
|
|
|
311
336
|
OUTPUT: a single JSON object, nothing else. Schema:
|
|
312
|
-
{{"changes": {{"audience_avoid": {{"add": [], "remove": []}}, "audience_prefer": {{"add": [], "remove": []}}, "thread_avoid": {{"add": [], "remove": []}}, "draft_style_notes": {{"add": [], "remove": []}}}}, "voice_never_add": [], "guardrails_do_not_add": [], "block_authors": [{{"handle": "somehandle", "reason": "one short sentence citing the evidence"}}], "rationale": "one short sentence"}}
|
|
337
|
+
{{"changes": {{"audience_avoid": {{"add": [], "remove": []}}, "audience_prefer": {{"add": [], "remove": []}}, "thread_avoid": {{"add": [], "remove": []}}, "draft_style_notes": {{"add": [], "remove": []}}}}, "voice_never_add": [], "voice_never_remove": [], "guardrails_do_not_add": [], "guardrails_do_not_remove": [], "block_authors": [{{"handle": "somehandle", "reason": "one short sentence citing the evidence"}}], "rationale": "one short sentence"}}
|
|
313
338
|
"remove" values must match existing entries EXACTLY. Omit empty keys if you like; an all-empty plan means "no changes"."""
|
|
314
339
|
|
|
315
340
|
|
|
@@ -467,6 +492,24 @@ def digest_project(project: dict, platform: str, dry_run: bool,
|
|
|
467
492
|
f"count={len(no_reason)} threshold={BULK_NO_REASON_THRESHOLD} "
|
|
468
493
|
f"event_ids=[{ids}] (volume signal, logged regardless of whether "
|
|
469
494
|
"the digest model finds a shared textual pattern below)")
|
|
495
|
+
# no_reason_given rejects (no category, no note): the user rejected but left
|
|
496
|
+
# no learnable reason, so DROP them from the digest entirely (2026-07-17 user
|
|
497
|
+
# rule). Mark them processed so they never clog the small fetch window, then
|
|
498
|
+
# remove them from the working set before any actionability / prompt logic.
|
|
499
|
+
if no_reason:
|
|
500
|
+
nr_ids = [int(e["id"]) for e in no_reason
|
|
501
|
+
if isinstance(e.get("id"), int) or str(e.get("id", "")).isdigit()]
|
|
502
|
+
if nr_ids and not dry_run:
|
|
503
|
+
try:
|
|
504
|
+
api_patch("/api/v1/review-events",
|
|
505
|
+
{"ids": nr_ids, "action": "mark_processed",
|
|
506
|
+
"processed_batch": f"digest-noreason-{_now_stamp()}"})
|
|
507
|
+
except Exception as e:
|
|
508
|
+
log(f"project={name} no_reason_mark_processed_failed={e}")
|
|
509
|
+
log(f"project={name} platform={platform} ignored_no_reason={len(no_reason)} "
|
|
510
|
+
"(not processed into learnings)")
|
|
511
|
+
_drop = {id(e) for e in no_reason}
|
|
512
|
+
events = [e for e in events if id(e) not in _drop]
|
|
470
513
|
if not overall_events and not platform_events and not any(_is_actionable(e) for e in events):
|
|
471
514
|
log(f"project={name} platform={platform} events={len(events)} "
|
|
472
515
|
"plain_approvals_only, no digest (they ride along with the next actionable event)")
|
package/scripts/find_threads.py
CHANGED
|
@@ -29,14 +29,15 @@ except Exception:
|
|
|
29
29
|
def _resolve_account(_platform): # type: ignore[unused-arg]
|
|
30
30
|
return None
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
33
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
34
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
35
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
36
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
37
|
+
from config import config_path as _canonical_config_path, load_config
|
|
38
|
+
CONFIG_PATH = _canonical_config_path()
|
|
33
39
|
|
|
34
40
|
|
|
35
|
-
def load_config():
|
|
36
|
-
if os.path.exists(CONFIG_PATH):
|
|
37
|
-
with open(CONFIG_PATH) as f:
|
|
38
|
-
return json.load(f)
|
|
39
|
-
return {}
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
|
|
@@ -451,7 +452,8 @@ def main():
|
|
|
451
452
|
project_config.get("subreddits", config.get("subreddits", []))
|
|
452
453
|
if project_config else config.get("subreddits", [])
|
|
453
454
|
)
|
|
454
|
-
|
|
455
|
+
from account_resolver import resolve as _resolve_account
|
|
456
|
+
reddit_username = _resolve_account("reddit") or ""
|
|
455
457
|
user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
|
|
456
458
|
|
|
457
459
|
# Rate limit check
|
package/scripts/github_tools.py
CHANGED
|
@@ -36,12 +36,15 @@ def _github_account_filter():
|
|
|
36
36
|
return (" AND our_account = %s", [h])
|
|
37
37
|
return ("", [])
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
40
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
41
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
42
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
43
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
44
|
+
from config import config_path as _canonical_config_path, load_config as _load_config
|
|
45
|
+
CONFIG_PATH = _canonical_config_path()
|
|
40
46
|
|
|
41
47
|
|
|
42
|
-
def _load_config():
|
|
43
|
-
with open(CONFIG_PATH) as f:
|
|
44
|
-
return json.load(f)
|
|
45
48
|
|
|
46
49
|
|
|
47
50
|
def _excluded_repos_and_authors(config):
|
|
@@ -88,9 +91,10 @@ def _save_repo_gone_cache(cache):
|
|
|
88
91
|
|
|
89
92
|
|
|
90
93
|
def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}}):
|
|
91
|
-
"""Fetch and cache (gone, has_issues, has_discussions) for
|
|
92
|
-
Two-tier cache (in-process + 24h on-disk JSON). Returns a dict
|
|
93
|
-
{gone: bool, has_issues: bool, has_discussions: bool}.
|
|
94
|
+
"""Fetch and cache (gone, has_issues, has_discussions, stars) for
|
|
95
|
+
owner/repo. Two-tier cache (in-process + 24h on-disk JSON). Returns a dict
|
|
96
|
+
{gone: bool, has_issues: bool, has_discussions: bool, stars: int|None}.
|
|
97
|
+
stars is None when the repo payload was unavailable (404, network)."""
|
|
94
98
|
key = f"{owner}/{repo}".lower()
|
|
95
99
|
if key in _mem:
|
|
96
100
|
return _mem[key]
|
|
@@ -100,11 +104,12 @@ def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}})
|
|
|
100
104
|
entry = _disk["data"].get(key)
|
|
101
105
|
now = int(time.time())
|
|
102
106
|
if (entry and (now - int(entry.get("checked_at", 0))) < _REPO_GONE_TTL_SEC
|
|
103
|
-
and "has_issues" in entry):
|
|
107
|
+
and "has_issues" in entry and "stars" in entry):
|
|
104
108
|
state = {
|
|
105
109
|
"gone": bool(entry.get("gone")),
|
|
106
110
|
"has_issues": bool(entry.get("has_issues", True)),
|
|
107
111
|
"has_discussions": bool(entry.get("has_discussions", True)),
|
|
112
|
+
"stars": entry.get("stars"),
|
|
108
113
|
}
|
|
109
114
|
_mem[key] = state
|
|
110
115
|
return state
|
|
@@ -114,7 +119,8 @@ def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}})
|
|
|
114
119
|
capture_output=True, text=True, timeout=20,
|
|
115
120
|
)
|
|
116
121
|
except Exception:
|
|
117
|
-
state = {"gone": False, "has_issues": True, "has_discussions": True
|
|
122
|
+
state = {"gone": False, "has_issues": True, "has_discussions": True,
|
|
123
|
+
"stars": None}
|
|
118
124
|
_mem[key] = state
|
|
119
125
|
return state
|
|
120
126
|
if proc.returncode == 0:
|
|
@@ -122,20 +128,27 @@ def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}})
|
|
|
122
128
|
data = json.loads(proc.stdout or "{}")
|
|
123
129
|
except Exception:
|
|
124
130
|
data = {}
|
|
131
|
+
try:
|
|
132
|
+
stars = int(data.get("stargazers_count"))
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
stars = None
|
|
125
135
|
state = {
|
|
126
136
|
"gone": False,
|
|
127
137
|
"has_issues": bool(data.get("has_issues", True)),
|
|
128
138
|
"has_discussions": bool(data.get("has_discussions", True)),
|
|
139
|
+
"stars": stars,
|
|
129
140
|
}
|
|
130
141
|
else:
|
|
131
142
|
err = ((proc.stderr or "") + (proc.stdout or "")).lower()
|
|
132
143
|
gone = ("not found" in err or "http 404" in err)
|
|
133
|
-
state = {"gone": gone, "has_issues": True, "has_discussions": True
|
|
144
|
+
state = {"gone": gone, "has_issues": True, "has_discussions": True,
|
|
145
|
+
"stars": None}
|
|
134
146
|
_mem[key] = state
|
|
135
147
|
_disk["data"][key] = {
|
|
136
148
|
"gone": state["gone"],
|
|
137
149
|
"has_issues": state["has_issues"],
|
|
138
150
|
"has_discussions": state["has_discussions"],
|
|
151
|
+
"stars": state["stars"],
|
|
139
152
|
"checked_at": now,
|
|
140
153
|
}
|
|
141
154
|
_save_repo_gone_cache(_disk["data"])
|