@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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +33 -9
  3. package/mcp/dist/index.js +290 -177
  4. package/mcp/dist/panel.html +31 -17
  5. package/mcp/dist/setup.js +27 -7
  6. package/mcp/dist/telemetry.js +15 -6
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +10 -2
  9. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  10. package/mcp/menubar/s4l_card.py +63 -4
  11. package/mcp/menubar/s4l_card_canvas.py +62 -11
  12. package/mcp/menubar/s4l_menubar.py +380 -95
  13. package/mcp/menubar/s4l_state.py +119 -41
  14. package/mcp/package.json +1 -1
  15. package/package.json +1 -1
  16. package/scripts/account_resolver.py +58 -14
  17. package/scripts/amplitude_24h_signups.py +7 -4
  18. package/scripts/amplitude_signups.py +7 -4
  19. package/scripts/autopilot_stall_watch.py +8 -1
  20. package/scripts/browser_lifecycle.py +90 -0
  21. package/scripts/cdp_ready_check.py +81 -50
  22. package/scripts/claude_job.py +70 -10
  23. package/scripts/dm_conversation.py +15 -12
  24. package/scripts/draft_prompt_core.py +132 -64
  25. package/scripts/engage_github.py +11 -5
  26. package/scripts/engage_reddit.py +12 -5
  27. package/scripts/engagement_signal_feedback.py +1 -4
  28. package/scripts/engagement_styles.py +144 -105
  29. package/scripts/feedback_digest.py +54 -11
  30. package/scripts/find_threads.py +9 -7
  31. package/scripts/github_tools.py +23 -10
  32. package/scripts/learned_preferences.py +112 -78
  33. package/scripts/li_discovery.py +4 -1
  34. package/scripts/link_tail.py +1 -1
  35. package/scripts/live_status.py +151 -0
  36. package/scripts/merge_review_queue.py +15 -2
  37. package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
  38. package/scripts/pick_project.py +7 -4
  39. package/scripts/pick_thread_target.py +64 -13
  40. package/scripts/pick_twitter_thread_target.py +7 -4
  41. package/scripts/platform_strike_events.py +139 -7
  42. package/scripts/post_github.py +376 -35
  43. package/scripts/post_reddit.py +202 -192
  44. package/scripts/preflight.sh +23 -3
  45. package/scripts/preview_reddit_card.py +38 -0
  46. package/scripts/project_stats.py +7 -4
  47. package/scripts/reddit_ban_check.py +324 -0
  48. package/scripts/reddit_browser.py +56 -19
  49. package/scripts/reddit_chat_sync.py +10 -11
  50. package/scripts/reddit_tools.py +20 -4
  51. package/scripts/release-mcpb.sh +47 -7
  52. package/scripts/run_moltbook_cycle.py +9 -5
  53. package/scripts/s4l_ctl.sh +2 -2
  54. package/scripts/s4l_posting_mode.py +67 -6
  55. package/scripts/scan_dm_candidates.py +15 -9
  56. package/scripts/scan_github_replies.py +9 -7
  57. package/scripts/scan_moltbook_replies.py +7 -6
  58. package/scripts/scan_reddit_replies.py +11 -7
  59. package/scripts/scan_twitter_mentions_browser.py +7 -6
  60. package/scripts/scan_twitter_thread_followups.py +7 -6
  61. package/scripts/schedule_state.py +8 -4
  62. package/scripts/score_twitter_candidates.py +8 -3
  63. package/scripts/setup_twitter_auth.py +20 -96
  64. package/scripts/snapshot.py +19 -0
  65. package/scripts/stats.py +12 -10
  66. package/scripts/strike_alert.py +165 -12
  67. package/scripts/sub_moderation_history.py +129 -0
  68. package/scripts/subreddit_bans_client.py +157 -0
  69. package/scripts/test_cross_language_parity.py +185 -0
  70. package/scripts/test_no_silent_fallbacks.py +84 -0
  71. package/scripts/test_percard_posting.py +3 -3
  72. package/scripts/test_stall_watch_batch_progression.py +99 -0
  73. package/scripts/top_performers.py +24 -0
  74. package/scripts/twitter_browser.py +22 -52
  75. package/scripts/twitter_gen_links.py +3 -3
  76. package/scripts/twitter_post_plan.py +4 -4
  77. package/scripts/virality.py +151 -0
  78. package/setup/SKILL.md +1 -1
  79. package/skill/engage-dm-replies.sh +11 -0
  80. package/skill/lib/reddit-backend.sh +1 -0
  81. package/skill/run-draft-and-publish.sh +16 -0
  82. package/skill/run-reddit-search-launchd.sh +1 -0
  83. package/skill/run-reddit-search.sh +10 -1
  84. package/skill/run-reddit-threads.sh +12 -0
  85. 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
- if assignment["mode"] == "use":
1467
- # Draft-prompt A/B v4 (voice-first, 2026-07-15 RESET of v3): v3's
1468
- # treatment rendered the style as the BINDING FORM of the draft
1469
- # (defining move + per-style length for EVERY assignment + self-check
1470
- # + two-layer learned_preferences contract). v4 replaces that
1471
- # wholesale: measured, real voice signal (the account's own corpus +
1472
- # voice.examples + learned_preferences, all handled in the
1473
- # DRAFT_DIRECTIVE built by run-twitter-cycle.sh) is meant to be the
1474
- # DOMINANT signal, and a generic cross-account style template
1475
- # dictating structure/length was competing with that. treatment_v4
1476
- # keeps the style as an optional idea for angle/length only, never a
1477
- # required shape. The arm is read from the env HERE because this
1478
- # block is rendered inside the cycle process where
1479
- # run-twitter-cycle.sh assigns and exports S4L_DRAFT_PROMPT_VARIANT
1480
- # (stamp-at-source: that same process also stamps the arm onto every
1481
- # plan candidate via active_experiments). Downstream/post-time
1482
- # consumers never re-read this env. Unset or control_v4 (and every
1483
- # non-twitter caller, which never has the env) renders the legacy
1484
- # block below unchanged — that block IS the v4 control arm, same as
1485
- # it was the v3 control arm; v1-v3 arm strings are retired and will
1486
- # never match here again, so this branch is now unreachable for any
1487
- # value except "treatment_v4".
1488
- _dp_arm = (os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or "").strip()
1489
- if _dp_arm == "treatment_v4":
1490
- _tc = assignment.get("target_chars") or DEFAULT_TARGET_CHARS
1491
- lines.append(
1492
- f"## Your assigned engagement style: **{assignment['style']}** "
1493
- "(an optional idea for angle and length, NOT a required structure)"
1494
- )
1495
- lines.append("")
1496
- lines.append(
1497
- f"This style was selected by the picker (weighted by live "
1498
- f"click-driven performance across {platform}). Draw on it for "
1499
- f"inspiration when it fits naturally. The voice/preferences "
1500
- f"priority order in the draft directive below takes precedence "
1501
- f"whenever it pulls in a different direction — never force this "
1502
- f"style's shape at the cost of that."
1503
- )
1504
- lines.append("")
1505
- lines.append(f"Platform tone: {policy.get('note', '')}")
1506
- lines.append("")
1507
- lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1508
- if assignment.get("example"):
1509
- lines.append(f' Example: "{assignment["example"]}"')
1510
- if assignment.get("note"):
1511
- lines.append(f" Note: {assignment['note']}")
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(_tc)} characters** "
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) unless the account's own real "
1517
- "examples run clearly shorter or longer, in which case match "
1518
- "them instead. This applies to the comment text only; any "
1519
- "link/CTA the system appends afterward is separate."
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
- f"This style was selected by the picker (weighted by live "
1526
- f"click-driven performance across {platform}). Use it. Do not "
1527
- f"swap it for a different listed style."
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
- "reddit": [
1839
- "Go BIMODAL: either 1 punchy sentence (<100 chars, highest avg upvotes) or 4-5 sentences of real substance. AVOID the 2-3 sentence dead zone.",
1840
- "Lead with personal experience or observation ('I tried X' / 'I noticed X' / 'the part that broke for me was X') rather than instructional advice ('you should X'). Whether you can speak AS the project's maker is governed by the VOICE RELATIONSHIP rule.",
1841
- "No markdown in Reddit (no ##, **, numbered lists). Casual tone, lowercase OK, fragments OK.",
1842
- "NEVER mention product names (fazm, assrt, pieline, cyrano, terminator, mk0r, s4l). NEVER include URLs or links.",
1843
- "Statements beat questions. Be authoritative, not inquisitive. No 'anyone else experience this?'",
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 = 200
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(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved, {len(plat_removed)} platform moderation strikes):
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. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
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 like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "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. When 2+ pairwise events agree, diff the picked texts against the not-picked ones and distill WHAT recurs (tone, structure, length, opener type, directness) into draft_style_notes; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
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. One removal is one community's judgment: treat it like a reasoned reject (it may justify one narrowly-scoped entry since the moderation context states the venue and outcome explicitly), and generalize only when 2+ removals share a pattern. 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) once 2+ bans or removals show it. Platform events never justify block_authors (there is no author at fault) and never touch audience_prefer.
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. Generalize only what the evidence supports: 2+ events agreeing justify a general entry; a single reject justifies at most one narrowly-scoped entry, and only when its note, interactions, or author context (follower count, their post, discovery topic) makes the reason explicit. Exceptions: a single loved approval can justify one audience_prefer/thread entry when the pattern it shows is clear, and OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one in the most fitting list even from a single line, rewritten as a standing preference.
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, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
307
- 6. Each entry: one sentence, under 200 characters, plain language, no em dashes, no hashtags, understandable a month from now without these events.
308
- 7. Respect the cap: at most {lp.MAX_ENTRIES_PER_LIST} entries per list. If a list is full, fold the new signal into an existing entry via remove+add.
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)")
@@ -29,14 +29,15 @@ except Exception:
29
29
  def _resolve_account(_platform): # type: ignore[unused-arg]
30
30
  return None
31
31
 
32
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
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
@@ -36,12 +36,15 @@ def _github_account_filter():
36
36
  return (" AND our_account = %s", [h])
37
37
  return ("", [])
38
38
 
39
- CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config.json")
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 owner/repo.
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"])