@m13v/s4l 1.7.5-rc.45 → 1.7.5-rc.46

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.5-rc.45",
3
- "installedAt": "2026-07-16T19:01:18.006Z"
2
+ "version": "1.7.5-rc.46",
3
+ "installedAt": "2026-07-16T19:12:33.534Z"
4
4
  }
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.5-rc.45",
5
+ "version": "1.7.5-rc.46",
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.5-rc.45",
3
+ "version": "1.7.5-rc.46",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.5-rc.45",
3
+ "version": "1.7.5-rc.46",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -38,6 +38,14 @@ Loved approvals (the stronger emoji in the card's inline approve row:
38
38
  strength as an approve_level_N interaction, and are surfaced to the model
39
39
  as strong positive evidence.
40
40
 
41
+ Platform-derived events (2026-07-16): each run first invokes
42
+ scripts/platform_strike_events.py to sweep fresh moderation outcomes into the
43
+ ledger (posts flipped to removed/deleted -> decision='platform_removed',
44
+ project-scoped; community bans -> decision='platform_banned', project NULL).
45
+ Project-scoped strikes flow through the normal per-project fetch; project-less
46
+ bans ride along into every project's prompt like overall feedback, rendered in
47
+ their own block. The prompt weighs both separately from human decisions.
48
+
41
49
  Failure handling: a Claude failure or unparseable plan leaves the events
42
50
  unprocessed for the next run. A run-level flock prevents concurrent digests.
43
51
 
@@ -185,7 +193,13 @@ def _event_line(e: dict) -> str:
185
193
  parts.append("edited_before_approving")
186
194
  line = " ".join(parts)
187
195
  if note:
188
- line += f"\n user note: {note[:300]}"
196
+ # Platform-derived events are machine-observed moderation facts, not
197
+ # something the user typed; label them honestly so the model never
198
+ # attributes the wording to the human reviewer.
199
+ label = ("moderation context"
200
+ if str(e.get("decision") or "").startswith("platform_")
201
+ else "user note")
202
+ line += f"\n {label}: {note[:300]}"
189
203
  tweet = (e.get("tweet_text") or "").strip()
190
204
  if tweet:
191
205
  line += f"\n their post was: {tweet[:200]}"
@@ -222,13 +236,17 @@ def _no_reason_rejects(events: list[dict]) -> list[dict]:
222
236
  and not (e.get("reject_note") or "").strip()]
223
237
 
224
238
 
225
- def build_prompt(project: dict, events: list[dict], overall_events: list[dict] | None = None) -> str:
239
+ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] | None = None,
240
+ platform_events: list[dict] | None = None) -> str:
226
241
  block = lp.get_block(project)
227
242
  overall_events = overall_events or []
243
+ platform_events = platform_events or []
228
244
  rejected = [e for e in events if e.get("decision") == "rejected"]
229
245
  no_reason = _no_reason_rejects(events)
230
246
  approved = [e for e in events if e.get("decision") == "approved"]
231
247
  loved = [e for e in approved if e.get("loved")]
248
+ plat_removed = [e for e in events
249
+ if str(e.get("decision") or "").startswith("platform_")]
232
250
  voice_never = ((project.get("voice") or {}).get("never")) or []
233
251
  guard_do_not = ((project.get("content_guardrails") or {}).get("do_not")) or []
234
252
 
@@ -247,6 +265,17 @@ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] |
247
265
  "explicit standing guidance about the whole pipeline, NOT about any single thread):\n"
248
266
  f"{notes}"
249
267
  )
268
+ platform_block = ""
269
+ if platform_events:
270
+ plat_lines = "\n".join(
271
+ f"{i + 1}. {_event_line(e)}" for i, e in enumerate(platform_events)
272
+ )
273
+ platform_block = (
274
+ f"\n\nPLATFORM-DERIVED ACCOUNT-LEVEL SIGNALS ({len(platform_events)} "
275
+ "event(s), machine-observed, not tied to one project; they ride into "
276
+ "every project's digest like overall feedback):\n"
277
+ f"{plat_lines}"
278
+ )
250
279
 
251
280
  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.
252
281
 
@@ -258,13 +287,15 @@ CURRENT learned_preferences (shared by every project):
258
287
  CURRENT voice.never: {json.dumps(voice_never)}
259
288
  CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
260
289
 
261
- 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):
262
- {ev_lines}{overall_block}
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):
291
+ {ev_lines}{overall_block}{platform_block}
263
292
 
264
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.
265
294
 
266
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.
267
296
 
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.
298
+
268
299
  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.
269
300
 
270
301
  Propose changes to the block. RULES, in priority order:
@@ -413,18 +444,21 @@ def _is_actionable(e: dict) -> bool:
413
444
 
414
445
 
415
446
  def digest_project(project: dict, platform: str, dry_run: bool,
416
- overall_events: list[dict] | None = None) -> bool:
417
- """Digest one project's pending events (plus any overall-feedback notes,
418
- which ride along in every project's prompt but are marked processed by
419
- main(), not here). Returns True when the digest completed (or there was
420
- nothing to do); False leaves the events unprocessed for the next run."""
447
+ overall_events: list[dict] | None = None,
448
+ platform_events: list[dict] | None = None) -> bool:
449
+ """Digest one project's pending events (plus any overall-feedback notes
450
+ and account-level platform events, which ride along in every project's
451
+ prompt but are marked processed by main(), not here). Returns True when
452
+ the digest completed (or there was nothing to do); False leaves the
453
+ events unprocessed for the next run."""
421
454
  name = project.get("name")
422
455
  overall_events = overall_events or []
456
+ platform_events = platform_events or []
423
457
  resp = api_get("/api/v1/review-events",
424
458
  {"project": name, "platform": platform, "unprocessed": "true",
425
459
  "limit": str(MAX_EVENTS_PER_RUN)})
426
460
  events = ((resp or {}).get("data") or {}).get("events") or []
427
- if not events and not overall_events:
461
+ if not events and not overall_events and not platform_events:
428
462
  return True
429
463
  no_reason = _no_reason_rejects(events)
430
464
  if len(no_reason) >= BULK_NO_REASON_THRESHOLD:
@@ -433,13 +467,14 @@ def digest_project(project: dict, platform: str, dry_run: bool,
433
467
  f"count={len(no_reason)} threshold={BULK_NO_REASON_THRESHOLD} "
434
468
  f"event_ids=[{ids}] (volume signal, logged regardless of whether "
435
469
  "the digest model finds a shared textual pattern below)")
436
- if not overall_events and not any(_is_actionable(e) for e in events):
470
+ if not overall_events and not platform_events and not any(_is_actionable(e) for e in events):
437
471
  log(f"project={name} platform={platform} events={len(events)} "
438
472
  "plain_approvals_only, no digest (they ride along with the next actionable event)")
439
473
  return True
440
- prompt = build_prompt(project, events, overall_events)
474
+ prompt = build_prompt(project, events, overall_events, platform_events)
441
475
  if dry_run:
442
- log(f"project={name} platform={platform} events={len(events)} overall={len(overall_events)} DRY RUN prompt below")
476
+ log(f"project={name} platform={platform} events={len(events)} overall={len(overall_events)} "
477
+ f"platform_ridealong={len(platform_events)} DRY RUN prompt below")
443
478
  print(prompt)
444
479
  ok, out, err = call_claude(prompt)
445
480
  if not ok:
@@ -567,6 +602,18 @@ def main() -> int:
567
602
  except Exception as e:
568
603
  log(f"learned_preferences migration error (non-fatal): {e}")
569
604
 
605
+ # Platform-strike sweep (2026-07-16): pull fresh moderation outcomes
606
+ # (removed/deleted posts, community bans) into the ledger before reading
607
+ # it, so this very run digests them. Idempotent (deterministic
608
+ # event_uuid), non-fatal, and skipped on --dry-run (which promises to
609
+ # change nothing).
610
+ if not args.dry_run:
611
+ try:
612
+ import platform_strike_events
613
+ platform_strike_events.sweep()
614
+ except Exception as e:
615
+ log(f"platform_strike_sweep_error (non-fatal): {e}")
616
+
570
617
  cfg = load_config()
571
618
  by_name = {p.get("name"): p for p in (cfg.get("projects") or [])}
572
619
 
@@ -577,20 +624,28 @@ def main() -> int:
577
624
  # button / menu bar "Send feedback…"): fetched once, folded into EVERY
578
625
  # configured project's prompt, and marked processed only after all
579
626
  # attempted digests succeed (apply_mutations dedups any re-digest).
627
+ # Project-less platform events (community bans, account-level by nature)
628
+ # ride along the same way but render in their own prompt block.
580
629
  overall_events: list[dict] = []
630
+ platform_events: list[dict] = []
581
631
  try:
582
632
  oresp = api_get("/api/v1/review-events",
583
633
  {"unprocessed": "true", "limit": "100"})
584
- overall_events = [
585
- e for e in (((oresp or {}).get("data") or {}).get("events") or [])
586
- if e.get("decision") == "feedback" and not e.get("project")
587
- ]
634
+ for e in (((oresp or {}).get("data") or {}).get("events") or []):
635
+ if e.get("project"):
636
+ continue
637
+ if e.get("decision") == "feedback":
638
+ overall_events.append(e)
639
+ elif str(e.get("decision") or "").startswith("platform_"):
640
+ platform_events.append(e)
588
641
  except Exception as e:
589
642
  log(f"overall_feedback_fetch_error={e}")
590
643
  if overall_events:
591
644
  log(f"overall feedback notes pending: {len(overall_events)}")
645
+ if platform_events:
646
+ log(f"account-level platform events pending: {len(platform_events)}")
592
647
 
593
- if not counts and not overall_events:
648
+ if not counts and not overall_events and not platform_events:
594
649
  log("no unprocessed review events")
595
650
  return 0
596
651
 
@@ -603,12 +658,13 @@ def main() -> int:
603
658
  n = int(row.get("unprocessed") or 0)
604
659
  if args.project and name != args.project:
605
660
  continue
606
- # Explicit overall feedback shouldn't wait on the card-event threshold.
607
- if n < args.min_events and not overall_events:
661
+ # Explicit overall feedback / platform events shouldn't wait on the
662
+ # card-event threshold.
663
+ if n < args.min_events and not overall_events and not platform_events:
608
664
  log(f"project={name} platform={platform} events={n} below_min={args.min_events}, waiting")
609
665
  continue
610
666
  todo[(name, platform)] = n
611
- if overall_events:
667
+ if overall_events or platform_events:
612
668
  for name in by_name:
613
669
  if args.project and name != args.project:
614
670
  continue
@@ -623,28 +679,30 @@ def main() -> int:
623
679
  continue
624
680
  attempted += 1
625
681
  try:
626
- if not digest_project(proj, platform, args.dry_run, overall_events):
682
+ if not digest_project(proj, platform, args.dry_run, overall_events,
683
+ platform_events):
627
684
  failures += 1
628
685
  except Exception as e:
629
686
  failures += 1
630
687
  log(f"project={name} digest_error={e}")
631
688
 
632
- if overall_events and not args.dry_run:
689
+ ridealong = overall_events + platform_events
690
+ if ridealong and not args.dry_run:
633
691
  if attempted and not failures:
634
- ids = [int(e["id"]) for e in overall_events
692
+ ids = [int(e["id"]) for e in ridealong
635
693
  if str(e.get("id", "")).isdigit() or isinstance(e.get("id"), int)]
636
694
  try:
637
695
  presp = api_patch("/api/v1/review-events",
638
696
  {"ids": ids, "action": "mark_processed",
639
697
  "processed_batch": f"digest-overall-{_now_stamp()}"})
640
698
  marked = ((presp or {}).get("data") or {}).get("updated") or 0
641
- log(f"overall feedback marked processed: {marked}")
699
+ log(f"overall/platform ride-along events marked processed: {marked}")
642
700
  except Exception as e:
643
701
  log(f"overall mark_processed_failed={e} (re-digested next run; apply dedups)")
644
702
  elif attempted:
645
- log("overall feedback left unprocessed (a project digest failed; next run retries)")
703
+ log("overall/platform events left unprocessed (a project digest failed; next run retries)")
646
704
  else:
647
- log("overall feedback pending but no configured project to digest into; left unprocessed")
705
+ log("overall/platform events pending but no configured project to digest into; left unprocessed")
648
706
  return 0
649
707
 
650
708
 
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """Sweep platform moderation strikes into the review_events ledger.
3
+
4
+ Two platform-derived decision types (2026-07-15 design, see
5
+ migrations/2026-07-15-review-platform-events.sql in the website repo):
6
+
7
+ platform_removed A post of ours flipped to status='removed'/'deleted'
8
+ (moderator removal, automod filter, or platform spam
9
+ filter). One event per post. Less severe: the lesson
10
+ is the CONTENT pattern, not the venue.
11
+ platform_banned Our account is blocked from a community (config.json
12
+ subreddit_bans.comment_blocked, reason
13
+ 'account_blocked_in_sub'). One event per (sub, account).
14
+ Severe: strong evidence against the venue. Emitted with
15
+ project=NULL (the ban is account-level, not tied to one
16
+ project); the digest folds these into every project's
17
+ prompt like overall feedback.
18
+
19
+ Idempotency: event_uuid is a uuid5 of the source fact (post id / sub+account),
20
+ NOT salted per install or per run, so re-runs AND concurrent sweeps from
21
+ sibling installs of the same fleet all collapse into one ledger row via the
22
+ API's ON CONFLICT (event_uuid) DO NOTHING.
23
+
24
+ Scoping: only posts whose project_name appears in the LOCAL config.json are
25
+ swept. This file ships in the release, so a customer install must never
26
+ mint events for other installs' posts; the local project list is exactly
27
+ the boundary of what this box is allowed to learn from. (posts GET is
28
+ deliberately fleet-wide, so own_install=true would drop the operator's
29
+ pre-install-stamping rows; the project filter is the correct scope.)
30
+
31
+ Recency gate: only posts DETECTED removed recently (status_checked_at within
32
+ --window-days, default 14) and bans ADDED within the same window. Removal
33
+ detection was dead 2026-05-28 to 2026-07-14, so without the gate the first
34
+ sweep would flood the digest with months of ancient history. Only rows with
35
+ deletion_detect_count >= 2 qualify (the same two-strike confirmation the
36
+ strike-alert email rail requires).
37
+
38
+ Invoked automatically at the start of every feedback_digest.py run (skipped
39
+ on --dry-run), or manually:
40
+
41
+ python3 scripts/platform_strike_events.py [--window-days N] [--dry-run]
42
+
43
+ stderr marker (load-bearing shape, keep stable):
44
+ [platform_strike_events] swept=... removed_events=... ban_events=... inserted=... duplicates=...
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import datetime
50
+ import json
51
+ import os
52
+ import re
53
+ import sys
54
+ import uuid
55
+ from pathlib import Path
56
+
57
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
58
+
59
+ from http_api import api_get, api_post, load_env # noqa: E402
60
+ # Stable namespace for deterministic event uuids. Never change this value:
61
+ # a new namespace would re-mint every historical fact as a "new" event.
62
+ EVENT_NS = uuid.uuid5(uuid.NAMESPACE_URL, "https://s4l.ai/platform-strike-events")
63
+ POST_BATCH = 100 # review-events POST MAX_BATCH
64
+ FETCH_LIMIT = 500 # posts GET hard cap; newest-first covers all fresh flips
65
+ MAX_NOTE = 1900 # route clips reject_note at 2000; stay under
66
+ MAX_DRAFT = 9500
67
+
68
+
69
+ def log(msg: str) -> None:
70
+ print(f"[platform_strike_events] {msg}", file=sys.stderr, flush=True)
71
+
72
+
73
+ def _now() -> datetime.datetime:
74
+ return datetime.datetime.now(datetime.timezone.utc)
75
+
76
+
77
+ def _parse_ts(raw) -> datetime.datetime | None:
78
+ if not raw:
79
+ return None
80
+ try:
81
+ return datetime.datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def _load_config() -> dict:
87
+ """Same resolution as the digester (state-dir first, symlinks resolved),
88
+ so producer scope and digest scope can never disagree."""
89
+ try:
90
+ from config import config_path
91
+ return json.loads(Path(config_path()).read_text())
92
+ except Exception:
93
+ return {}
94
+
95
+
96
+ def _venue(post: dict) -> str | None:
97
+ """Human-readable venue for the note: subreddit for reddit, repo for
98
+ github, otherwise nothing (twitter has no venue concept)."""
99
+ url = post.get("thread_url") or post.get("our_url") or ""
100
+ m = re.search(r"reddit\.com/r/([^/]+)/", url)
101
+ if m:
102
+ return f"r/{m.group(1)}"
103
+ m = re.search(r"github\.com/([^/]+/[^/#?]+)", url)
104
+ if m:
105
+ return m.group(1)
106
+ return None
107
+
108
+
109
+ def _removed_event(post: dict) -> dict:
110
+ venue = _venue(post)
111
+ is_own_thread = bool(post.get("thread_url")) and post.get("thread_url") == post.get("our_url")
112
+ kind = "top-level post" if is_own_thread else "comment/reply"
113
+ posted = str(post.get("posted_at") or "")[:10]
114
+ detected = str(post.get("status_checked_at") or "")[:10]
115
+ note = (
116
+ f"Platform moderation strike: our {post.get('platform')} {kind}"
117
+ f"{' in ' + venue if venue else ''} is now status='{post.get('status')}'"
118
+ f" (confirmed on {post.get('deletion_detect_count')} consecutive scans)."
119
+ f" Posted {posted}, removal detected {detected},"
120
+ f" account {post.get('our_account')}."
121
+ f" Machine-derived signal, not a human card decision: a moderator or"
122
+ f" platform filter judged this content unwelcome in that venue."
123
+ )
124
+ return {
125
+ "event_uuid": str(uuid.uuid5(EVENT_NS, f"platform_removed:post:{post['id']}")),
126
+ "decision": "platform_removed",
127
+ "platform": post.get("platform") or "reddit",
128
+ "project": post.get("project_name"),
129
+ "thread_url": (post.get("our_url") or post.get("thread_url") or "")[:1000] or None,
130
+ "thread_author": post.get("thread_author"),
131
+ "draft_text": (post.get("our_content") or "")[:MAX_DRAFT] or None,
132
+ "reject_note": note[:MAX_NOTE],
133
+ "batch_id": "platform_strike_sweep",
134
+ }
135
+
136
+
137
+ def _ban_event(entry: dict) -> dict:
138
+ sub = str(entry.get("sub") or "").strip().lower()
139
+ account = str(entry.get("account") or "").strip()
140
+ added = str(entry.get("added_at") or "")[:10]
141
+ note = (
142
+ f"Community ban: our reddit account {account or '(unknown)'} is blocked"
143
+ f" from participating in r/{sub} (reason: {entry.get('reason')},"
144
+ f" noticed {added or 'unknown date'})."
145
+ f" Machine-derived signal: the venue itself rejected the account,"
146
+ f" the strongest platform-side evidence against this venue/content pairing."
147
+ )
148
+ return {
149
+ "event_uuid": str(uuid.uuid5(EVENT_NS, f"platform_banned:reddit:{sub}:{account.lower()}")),
150
+ "decision": "platform_banned",
151
+ "platform": "reddit",
152
+ "project": entry.get("noticed_by_project") or None,
153
+ "thread_url": f"https://www.reddit.com/r/{sub}/" if sub else None,
154
+ "reject_note": note[:MAX_NOTE],
155
+ "batch_id": "platform_strike_sweep",
156
+ }
157
+
158
+
159
+ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
160
+ """Returns (events, removed_count, ban_count)."""
161
+ cfg = _load_config()
162
+ local_projects = {p.get("name") for p in (cfg.get("projects") or []) if p.get("name")}
163
+ cutoff = _now() - datetime.timedelta(days=window_days)
164
+
165
+ resp = api_get("/api/v1/posts", {
166
+ "statuses": "removed,deleted",
167
+ "order_by": "id",
168
+ "order_dir": "desc",
169
+ "limit": str(FETCH_LIMIT),
170
+ })
171
+ posts = ((resp or {}).get("data") or {}).get("posts") or []
172
+
173
+ removed_events = []
174
+ for p in posts:
175
+ if p.get("project_name") not in local_projects:
176
+ continue
177
+ if int(p.get("deletion_detect_count") or 0) < 2:
178
+ continue
179
+ detected = _parse_ts(p.get("status_checked_at"))
180
+ if detected is None or detected < cutoff:
181
+ continue
182
+ removed_events.append(_removed_event(p))
183
+
184
+ ban_events = []
185
+ for entry in ((cfg.get("subreddit_bans") or {}).get("comment_blocked") or []):
186
+ if entry.get("reason") != "account_blocked_in_sub":
187
+ continue
188
+ added = _parse_ts(entry.get("added_at"))
189
+ if added is None or added < cutoff:
190
+ continue
191
+ ban_events.append(_ban_event(entry))
192
+
193
+ return removed_events + ban_events, len(removed_events), len(ban_events)
194
+
195
+
196
+ def sweep(window_days: int = 14, dry_run: bool = False) -> dict:
197
+ events, n_removed, n_banned = collect_events(window_days)
198
+ inserted = 0
199
+ duplicates = 0
200
+ if dry_run:
201
+ for ev in events:
202
+ print(json.dumps(ev, indent=2))
203
+ else:
204
+ for i in range(0, len(events), POST_BATCH):
205
+ resp = api_post("/api/v1/review-events", {"events": events[i:i + POST_BATCH]})
206
+ data = (resp or {}).get("data") or {}
207
+ inserted += int(data.get("inserted") or 0)
208
+ duplicates += int(data.get("duplicates") or 0)
209
+ log(
210
+ f"swept={len(events)} removed_events={n_removed} ban_events={n_banned}"
211
+ f" inserted={inserted} duplicates={duplicates}"
212
+ f"{' (dry run, nothing posted)' if dry_run else ''}"
213
+ )
214
+ return {"swept": len(events), "removed": n_removed, "banned": n_banned,
215
+ "inserted": inserted, "duplicates": duplicates}
216
+
217
+
218
+ def main() -> int:
219
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
220
+ ap.add_argument("--window-days", type=int,
221
+ default=int(os.environ.get("S4L_PLATFORM_STRIKE_WINDOW_DAYS", "14")),
222
+ help="sweep strikes detected within the last N days")
223
+ ap.add_argument("--dry-run", action="store_true",
224
+ help="print the events instead of posting them")
225
+ args = ap.parse_args()
226
+ load_env()
227
+ sweep(args.window_days, args.dry_run)
228
+ return 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ sys.exit(main())