@m13v/s4l 1.7.5-rc.44 → 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.
- package/mcp/dist/index.js +51 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/feedback_digest.py +85 -27
- package/scripts/platform_strike_events.py +232 -0
package/mcp/dist/index.js
CHANGED
|
@@ -1011,6 +1011,27 @@ async function postApproved(batchId, plan) {
|
|
|
1011
1011
|
plan = fresh;
|
|
1012
1012
|
}
|
|
1013
1013
|
}
|
|
1014
|
+
// Arm the cross-instance posting flag HERE, before the Reddit drain, not just
|
|
1015
|
+
// before the Twitter phase below. Root cause (found 2026-07-16): this flag
|
|
1016
|
+
// used to get set only right before ensureTwitterBrowserForPost(), AFTER the
|
|
1017
|
+
// whole Reddit drain loop above (one runPython("post_reddit.py") call per
|
|
1018
|
+
// approved Reddit card, each with real CDP/browser overhead — a batch of a
|
|
1019
|
+
// handful of cards routinely runs minutes). For that entire window this
|
|
1020
|
+
// instance's isPeerDrainActive() reported false, so a SECOND MCP instance's
|
|
1021
|
+
// postApproved() (e.g. a fresh one-shot worker boot running its own startup
|
|
1022
|
+
// backlog drain) could pass the SAME gate check above and start its own
|
|
1023
|
+
// drain concurrently. Both eventually reached their own Twitter phase and
|
|
1024
|
+
// both spawned scripts/twitter_post_plan.py with S4L_LOCK_ROLE=post — the
|
|
1025
|
+
// ONLY code path that ever sets that role. Two live "post" role holders
|
|
1026
|
+
// can't preempt each other (browser_mutex.py case 6: correct, preempting a
|
|
1027
|
+
// peer mid-post risks a double-post), so one always waited 45s and gave up
|
|
1028
|
+
// — the "Twitter browser locked by session python:<pid> ... waited 45s,
|
|
1029
|
+
// giving up" failures that silently killed 11 real approved drafts over
|
|
1030
|
+
// 2026-07-15/16. Arming the flag for the FULL function closes that window:
|
|
1031
|
+
// a peer's gate check now blocks for the entire drain, Reddit included, so
|
|
1032
|
+
// only one instance ever reaches the Twitter phase at a time.
|
|
1033
|
+
postingActive = true;
|
|
1034
|
+
startPostingFlagHeartbeat();
|
|
1014
1035
|
// Post every card the user APPROVED that hasn't already landed or been ruled out.
|
|
1015
1036
|
// `approved` is now a DURABLE decision (sticky, never cleared by a later call), so
|
|
1016
1037
|
// filtering out posted/terminal here makes this idempotent: re-running it only
|
|
@@ -1029,8 +1050,11 @@ async function postApproved(batchId, plan) {
|
|
|
1029
1050
|
delete c.discard_reason;
|
|
1030
1051
|
}
|
|
1031
1052
|
}
|
|
1032
|
-
if (approved.length === 0)
|
|
1053
|
+
if (approved.length === 0) {
|
|
1054
|
+
postingActive = false;
|
|
1055
|
+
stopPostingFlagHeartbeat();
|
|
1033
1056
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
1057
|
+
}
|
|
1034
1058
|
// ---- Reddit cards (2026-07-14): drain them FIRST, independently ----------
|
|
1035
1059
|
// A reddit card carries the verbatim draft decision (reddit_decision) plus
|
|
1036
1060
|
// plan metadata (reddit_plan_meta), so approval reconstructs a one-decision
|
|
@@ -1204,7 +1228,10 @@ async function postApproved(batchId, plan) {
|
|
|
1204
1228
|
}
|
|
1205
1229
|
if (approvedTwitter.length === 0) {
|
|
1206
1230
|
// All-reddit batch: persist the stamps and return without touching the
|
|
1207
|
-
// twitter preflight/lock path.
|
|
1231
|
+
// twitter preflight/lock path. Must clear the flag armed above ourselves —
|
|
1232
|
+
// this branch never reaches the Twitter phase's scheduleShellLockRelease().
|
|
1233
|
+
postingActive = false;
|
|
1234
|
+
stopPostingFlagHeartbeat();
|
|
1208
1235
|
if (redditSkippedPeerDrain) {
|
|
1209
1236
|
return {
|
|
1210
1237
|
attempted: 0,
|
|
@@ -1230,6 +1257,8 @@ async function postApproved(batchId, plan) {
|
|
|
1230
1257
|
if (!readConfiguredTwitterHandle())
|
|
1231
1258
|
await ensurePostingHandle();
|
|
1232
1259
|
if (!readConfiguredTwitterHandle()) {
|
|
1260
|
+
postingActive = false;
|
|
1261
|
+
stopPostingFlagHeartbeat();
|
|
1233
1262
|
return {
|
|
1234
1263
|
attempted: 0,
|
|
1235
1264
|
exit_code: 0,
|
|
@@ -2863,7 +2892,22 @@ tool("post_drafts", {
|
|
|
2863
2892
|
warnings,
|
|
2864
2893
|
});
|
|
2865
2894
|
}
|
|
2866
|
-
|
|
2895
|
+
// postApproved now arms the cross-instance posting flag before the Reddit
|
|
2896
|
+
// drain (see its own comment), not just before the Twitter phase. Every
|
|
2897
|
+
// op inside that drain is already individually best-effort try/caught, so
|
|
2898
|
+
// this is belt-and-suspenders: if something still throws past all of
|
|
2899
|
+
// that, clear the flag before it escapes rather than leave this MCP
|
|
2900
|
+
// instance's posting permanently gated behind a flag nothing will ever
|
|
2901
|
+
// reset.
|
|
2902
|
+
let result;
|
|
2903
|
+
try {
|
|
2904
|
+
result = await postApproved(batch_id, plan);
|
|
2905
|
+
}
|
|
2906
|
+
catch (e) {
|
|
2907
|
+
postingActive = false;
|
|
2908
|
+
stopPostingFlagHeartbeat();
|
|
2909
|
+
throw e;
|
|
2910
|
+
}
|
|
2867
2911
|
// Report the REAL posted count from the pipeline, not the approved count.
|
|
2868
2912
|
// A run can approve N yet land 0 (browser/session failure); reporting
|
|
2869
2913
|
// approve.size here told the agent "posted: N" on a total failure.
|
|
@@ -5658,6 +5702,10 @@ async function drainApprovedBacklog() {
|
|
|
5658
5702
|
}
|
|
5659
5703
|
catch (e) {
|
|
5660
5704
|
console.error("[post] drainApprovedBacklog error:", e?.message || e);
|
|
5705
|
+
// Same reasoning as the other postApproved call site: don't let an
|
|
5706
|
+
// escaped exception leave the cross-instance posting flag stuck true.
|
|
5707
|
+
postingActive = false;
|
|
5708
|
+
stopPostingFlagHeartbeat();
|
|
5661
5709
|
}
|
|
5662
5710
|
}
|
|
5663
5711
|
async function main() {
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.5-rc.
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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)}
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
|
607
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
703
|
+
log("overall/platform events left unprocessed (a project digest failed; next run retries)")
|
|
646
704
|
else:
|
|
647
|
-
log("overall
|
|
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())
|