@m13v/s4l 1.7.2 → 1.7.4-rc.1

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.
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a scan_x_profile.py corpus into durable author-voice exemplars.
3
+
4
+ WHERE THIS FITS: scan_x_profile.py (run at onboarding right after connect_x,
5
+ or later via build_persona.py gather) returns the user's real posts + replies
6
+ ranked by engagement (`top_posts` / `top_replies`, each with likes/retweets/
7
+ replies, the parent tweet replied to, and thread continuations). This script is
8
+ the single deterministic writer that persists them where the drafters already
9
+ look:
10
+
11
+ 1. config.json -> project.voice.examples (list of strings). Every drafting
12
+ prompt already says "mirror voice.examples / voice.examples_good when
13
+ present": the twitter cycle (both promotion and personal_brand lanes, via
14
+ ALL_PROJECTS_JSON), post_reddit.py, engage_reddit.py, post_github.py, and
15
+ run_moltbook_cycle.py. Writing this ONE field feeds every lane on every
16
+ platform with zero prompt changes.
17
+ 2. persona_corpus.txt (persona project only) -> a marked, regenerable
18
+ exemplar section with the top posts (incl. thread continuations) and top
19
+ replies WITH their parent context and stats. The personal_brand lane
20
+ inlines this file into the draft prompt.
21
+
22
+ Selection: prefers replies with real engagement and enough text to show voice
23
+ (skips throwaway one-liners below --min-chars unless nothing else exists).
24
+ Stats are framed RELATIVELY ("top of the N scanned replies") because absolute
25
+ numbers on small accounts mean nothing to the model.
26
+
27
+ Safety: never clobbers hand-written voice.examples. It only replaces examples
28
+ it wrote itself (voice.examples_scanned_at present) or fills an empty field;
29
+ use --force to override. Config writes are atomic (tmp + replace), matching
30
+ build_persona.py.
31
+
32
+ Usage:
33
+ # After a scan (writes voice.examples; also corpus section if project is the persona):
34
+ python3 scripts/scan_x_profile.py > /tmp/scan.json
35
+ python3 scripts/voice_exemplars.py apply --scan /tmp/scan.json --project MyProject
36
+
37
+ # Preview without writing:
38
+ python3 scripts/voice_exemplars.py apply --scan /tmp/scan.json --project MyProject --dry-run
39
+
40
+ # Default project = the persona project (persona:true) when --project omitted.
41
+
42
+ Accepts either raw scan_x_profile.py output or a build_persona.py gather blob
43
+ (auto-detects sources.x).
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import json
50
+ import os
51
+ import shutil
52
+ import subprocess
53
+ import sys
54
+ import time
55
+ from datetime import datetime, timezone
56
+ from pathlib import Path
57
+
58
+ HERE = Path(__file__).resolve().parent
59
+ sys.path.insert(0, str(HERE))
60
+
61
+ import s4l_mode # noqa: E402
62
+
63
+ EXEMPLARS_START = "=== S4L SCANNED EXEMPLARS START (auto-generated by voice_exemplars.py; regenerated on rescan, do not hand-edit) ==="
64
+ EXEMPLARS_END = "=== S4L SCANNED EXEMPLARS END ==="
65
+
66
+
67
+ def _score(item: dict) -> int:
68
+ """Mirror scan_x_profile.rank_top's weighting for scans that predate it."""
69
+ if "engagement_score" in item:
70
+ return int(item["engagement_score"] or 0)
71
+ return (int(item.get("likes") or 0) * 3
72
+ + int(item.get("retweets") or 0) * 5
73
+ + int(item.get("replies") or 0) * 2)
74
+
75
+
76
+ def _load_scan(path: str) -> dict:
77
+ data = json.loads(Path(path).expanduser().read_text())
78
+ # build_persona.py gather embeds the raw scan under sources.x
79
+ if "sources" in data and isinstance(data["sources"], dict):
80
+ x = data["sources"].get("x") or {}
81
+ if x.get("ok"):
82
+ return x
83
+ raise SystemExit(f"gather blob's x source is not ok: {x.get('error')}")
84
+ return data
85
+
86
+
87
+ def _pick(items: list, top: int, min_chars: int) -> list:
88
+ """Engagement-ranked, but a reply must have enough text to show voice.
89
+ Short high-engagement one-liners get skipped while longer candidates exist;
90
+ if the account only has one-liners, fall back rather than return nothing."""
91
+ ranked = sorted(items or [], key=_score, reverse=True)
92
+ long_enough = [i for i in ranked if len((i.get("text") or "").strip()) >= min_chars]
93
+ picked = long_enough[:top]
94
+ if not picked:
95
+ picked = ranked[:top]
96
+ return picked
97
+
98
+
99
+ def _reply_pool(scan: dict) -> list:
100
+ """Select from the FULL scanned reply list, not the scanner's pre-truncated
101
+ top_replies (length-filtering a 5-item list can leave one survivor when the
102
+ account's best-engaging replies are one-liners). top_replies is the
103
+ fallback for older scans / gather blobs that dropped the full list."""
104
+ return scan.get("comments") or scan.get("top_replies") or []
105
+
106
+
107
+ def _post_pool(scan: dict) -> list:
108
+ """Full post list, but prefer the enriched top_posts entry where one exists
109
+ (it carries the thread continuation and untruncated permalink text)."""
110
+ top = scan.get("top_posts") or []
111
+ rest = scan.get("posts") or []
112
+ by_key = {(p.get("id") or p.get("url") or p.get("text", "")[:80]): p for p in rest}
113
+ for p in top:
114
+ by_key[(p.get("id") or p.get("url") or p.get("text", "")[:80])] = p
115
+ return list(by_key.values())
116
+
117
+
118
+ def _stats_note(item: dict, total: int, kind: str) -> str:
119
+ bits = []
120
+ for key, label in (("likes", "likes"), ("retweets", "reposts"), ("replies", "replies")):
121
+ v = int(item.get(key) or 0)
122
+ if v:
123
+ bits.append(f"{v} {label}")
124
+ eng = ", ".join(bits) if bits else "no engagement yet"
125
+ return f"{eng}; among their best of {total} scanned {kind}"
126
+
127
+
128
+ def format_examples(scan: dict, top: int, min_chars: int) -> list[str]:
129
+ """voice.examples strings: verbatim reply first (the voice signal), then a
130
+ parenthesized context annotation (real stats + what it replied to). Plain
131
+ strings because every consumer joins/inlines them as-is."""
132
+ replies = _reply_pool(scan)
133
+ total = int((scan.get("counts") or {}).get("comments") or len(replies))
134
+ out = []
135
+ for r in _pick(replies, top, min_chars):
136
+ text = (r.get("text") or "").strip()
137
+ if not text:
138
+ continue
139
+ note = _stats_note(r, total, "replies")
140
+ parent = r.get("parent") or {}
141
+ if parent.get("text"):
142
+ ptxt = " ".join(parent["text"].split())[:140]
143
+ note += f'; replying to {parent.get("author") or "someone"}: "{ptxt}"'
144
+ out.append(f'"{text}" (their real reply, {note})')
145
+ return out
146
+
147
+
148
+ def format_corpus_section(scan: dict, top: int, min_chars: int) -> str:
149
+ """The persona_corpus.txt block: richer than voice.examples (adds top posts
150
+ and full thread continuations). Regenerated wholesale between the markers."""
151
+ handle = scan.get("handle") or "?"
152
+ counts = scan.get("counts") or {}
153
+ lines = [EXEMPLARS_START,
154
+ f"@{handle}'s own top-performing posts and replies, ranked by real "
155
+ f"engagement across {counts.get('posts', '?')} posts and "
156
+ f"{counts.get('comments', '?')} replies scanned. Verbatim. This is "
157
+ "the voice and the bar: ground drafts in this register.", ""]
158
+ posts = _pick(_post_pool(scan), top, min_chars)
159
+ if posts:
160
+ lines.append("TOP POSTS:")
161
+ for p in posts:
162
+ lines.append(f"- ({_stats_note(p, int(counts.get('posts') or len(posts)), 'posts')}) {p.get('text', '').strip()}")
163
+ for cont in (p.get("thread") or [])[1:]:
164
+ lines.append(f" [thread continuation] {cont.strip()}")
165
+ lines.append("")
166
+ replies = _pick(_reply_pool(scan), top, min_chars)
167
+ if replies:
168
+ lines.append("TOP REPLIES (with what they replied to):")
169
+ for r in replies:
170
+ parent = r.get("parent") or {}
171
+ if parent.get("text"):
172
+ ptxt = " ".join(parent["text"].split())[:200]
173
+ lines.append(f'- {parent.get("author") or "someone"} wrote: "{ptxt}"')
174
+ lines.append(f" their reply ({_stats_note(r, int(counts.get('comments') or len(replies)), 'replies')}): {r.get('text', '').strip()}")
175
+ else:
176
+ lines.append(f"- their reply ({_stats_note(r, int(counts.get('comments') or len(replies)), 'replies')}): {r.get('text', '').strip()}")
177
+ lines.append(EXEMPLARS_END)
178
+ return "\n".join(lines)
179
+
180
+
181
+ def _upsert_corpus_section(corpus_path: Path, section: str) -> str:
182
+ """Replace the marked section in persona_corpus.txt, or prepend it (top of
183
+ file for salience) when absent. Returns what happened."""
184
+ if corpus_path.exists():
185
+ body = corpus_path.read_text()
186
+ if EXEMPLARS_START in body and EXEMPLARS_END in body:
187
+ pre = body.split(EXEMPLARS_START)[0]
188
+ post = body.split(EXEMPLARS_END, 1)[1]
189
+ corpus_path.write_text((pre + section + post).strip() + "\n")
190
+ return "replaced"
191
+ corpus_path.write_text(section + "\n\n" + body.strip() + "\n")
192
+ return "prepended"
193
+ corpus_path.write_text(section + "\n")
194
+ return "created"
195
+
196
+
197
+ def _is_hand_written(proj: dict) -> bool:
198
+ """examples present but never stamped by us = a human typed them."""
199
+ voice = proj.get("voice")
200
+ return (isinstance(voice, dict) and bool(voice.get("examples"))
201
+ and not voice.get("examples_scanned_at"))
202
+
203
+
204
+ def _set_examples(proj: dict, scan: dict, top: int, min_chars: int,
205
+ force: bool) -> "tuple[bool, str, list]":
206
+ """Write voice.examples onto proj IN PLACE. Returns (ok, reason, examples)."""
207
+ examples = format_examples(scan, top, min_chars)
208
+ if not examples:
209
+ return False, "no_usable_replies", []
210
+ if _is_hand_written(proj) and not force:
211
+ return False, "hand_written_examples", []
212
+ voice = proj.get("voice")
213
+ if not isinstance(voice, dict):
214
+ voice = {"tone": voice} if voice else {}
215
+ proj["voice"] = voice
216
+ voice["examples"] = examples
217
+ voice["examples_scanned_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
218
+ return True, "ok", examples
219
+
220
+
221
+ def _write_config(cfg_path: Path, cfg: dict) -> None:
222
+ tmp = cfg_path.with_suffix(".json.tmp")
223
+ tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n")
224
+ tmp.replace(cfg_path)
225
+
226
+
227
+ def cmd_apply(args) -> int:
228
+ cfg_path = Path(args.config).expanduser() if args.config else s4l_mode.config_path()
229
+ scan_src = args.scan or str(cfg_path.parent / "last_profile_scan.json")
230
+ if not Path(scan_src).expanduser().exists():
231
+ print(json.dumps({"ok": False, "error": f"no scan at {scan_src}; run "
232
+ "scan_x_profile.py first (it writes last_profile_scan.json) "
233
+ "or pass --scan"}))
234
+ return 1
235
+ scan = _load_scan(scan_src)
236
+ if not scan.get("ok"):
237
+ print(json.dumps({"ok": False, "error": f"scan not ok: {scan.get('error')}"}))
238
+ return 1
239
+
240
+ cfg = json.loads(cfg_path.read_text())
241
+ projects = cfg.get("projects", [])
242
+ name = args.project or s4l_mode.persona_name()
243
+ proj = next((p for p in projects if p.get("name") == name), None)
244
+ if proj is None:
245
+ print(json.dumps({"ok": False, "error": f"project {name!r} not in {cfg_path} "
246
+ "(pass --project, or configure a persona project)"}))
247
+ return 2
248
+
249
+ ok, reason, examples = _set_examples(proj, scan, args.top, args.min_chars, args.force)
250
+ if not ok and reason == "no_usable_replies":
251
+ print(json.dumps({"ok": False, "error": "scan has no usable replies to build examples from"}))
252
+ return 1
253
+ if not ok:
254
+ print(json.dumps({"ok": False, "error":
255
+ f"{name}.voice.examples already has hand-written entries; "
256
+ "rerun with --force to replace them",
257
+ "existing": (proj.get("voice") or {}).get("examples")}))
258
+ return 3
259
+
260
+ is_persona = proj.get("persona") is True
261
+ corpus_path = cfg_path.parent / "persona_corpus.txt"
262
+ section = format_corpus_section(scan, args.top, args.min_chars) if is_persona else None
263
+
264
+ if args.dry_run:
265
+ print(json.dumps({"ok": True, "dry_run": True, "project": name,
266
+ "voice_examples": examples,
267
+ "corpus_section": section}, ensure_ascii=False, indent=2))
268
+ return 0
269
+
270
+ _write_config(cfg_path, cfg)
271
+
272
+ corpus_action = None
273
+ if section:
274
+ corpus_action = _upsert_corpus_section(corpus_path, section)
275
+
276
+ print(json.dumps({"ok": True, "project": name,
277
+ "voice_examples_written": len(examples),
278
+ "corpus_sidecar": str(corpus_path) if corpus_action else None,
279
+ "corpus_action": corpus_action}, ensure_ascii=False))
280
+ return 0
281
+
282
+
283
+ # --------------------------------------------------------------------------- #
284
+ # backfill: one-shot catch-up for installs onboarded BEFORE this feature.
285
+ # --------------------------------------------------------------------------- #
286
+ _BROWSER_LOCK = Path("/tmp/social-autoposter-twitter-browser.lock")
287
+
288
+
289
+ def _try_browser_lock() -> "Path | None":
290
+ """BAIL-ON-BUSY acquire of the pipelines' twitter-browser lock (same mkdir
291
+ protocol as skill/lock.sh, without its queue: the backfill is opportunistic
292
+ and simply retries on a later boot rather than waiting behind a cycle)."""
293
+ try:
294
+ _BROWSER_LOCK.mkdir()
295
+ except OSError:
296
+ return None
297
+ try:
298
+ (_BROWSER_LOCK / "pid").write_text(f"{os.getpid()}\n")
299
+ (_BROWSER_LOCK / "expires_at").write_text(f"{int(time.time()) + 600}\n")
300
+ except Exception:
301
+ pass
302
+ return _BROWSER_LOCK
303
+
304
+
305
+ def _release_browser_lock() -> None:
306
+ try:
307
+ pid = (_BROWSER_LOCK / "pid").read_text().strip()
308
+ if pid == str(os.getpid()):
309
+ shutil.rmtree(_BROWSER_LOCK)
310
+ except Exception:
311
+ pass
312
+
313
+
314
+ def cmd_backfill(args) -> int:
315
+ """Called best-effort on MCP boot. If the persona project predates the
316
+ exemplar feature (no voice.examples_scanned_at), run a fresh profile scan
317
+ and apply. Scoped to the PERSONA project only: every install provisions one
318
+ at onboarding, and it is where the author's own voice lives; product
319
+ projects pick exemplars up on their next project_config save instead. The
320
+ corpus write is additive (the marked section is the only thing replaced;
321
+ dictation and hand-added material stay). Rate-limited by a marker file so a
322
+ down browser doesn't retrigger a scan on every boot."""
323
+ cfg_path = Path(args.config).expanduser() if args.config else s4l_mode.config_path()
324
+ if not cfg_path.exists():
325
+ print(json.dumps({"ok": True, "did": "nothing", "reason": "no_config"}))
326
+ return 0
327
+ cfg = json.loads(cfg_path.read_text())
328
+ personas = [p for p in cfg.get("projects", []) if p.get("persona") is True]
329
+ eligible = [p for p in personas
330
+ if not (isinstance(p.get("voice"), dict) and p["voice"].get("examples_scanned_at"))
331
+ and not _is_hand_written(p)]
332
+ if not eligible:
333
+ print(json.dumps({"ok": True, "did": "nothing", "reason": "already_done_or_hand_written"}))
334
+ return 0
335
+
336
+ marker = cfg_path.parent / ".voice_exemplars_backfill.json"
337
+ try:
338
+ last = json.loads(marker.read_text()).get("last_attempt", 0)
339
+ except Exception:
340
+ last = 0
341
+ if time.time() - last < args.min_hours_between * 3600:
342
+ print(json.dumps({"ok": True, "did": "nothing", "reason": "attempted_recently"}))
343
+ return 0
344
+ marker.write_text(json.dumps({"last_attempt": int(time.time())}) + "\n")
345
+
346
+ if not args.no_scan:
347
+ lock = _try_browser_lock()
348
+ if lock is None:
349
+ print(json.dumps({"ok": True, "did": "nothing", "reason": "browser_busy"}))
350
+ return 0
351
+ try:
352
+ py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
353
+ r = subprocess.run([py, str(HERE / "scan_x_profile.py")],
354
+ capture_output=True, text=True, timeout=300)
355
+ except Exception as e:
356
+ print(json.dumps({"ok": False, "did": "nothing", "reason": f"scan_failed: {e}"}))
357
+ return 0
358
+ finally:
359
+ _release_browser_lock()
360
+ last_line = (r.stdout or "").strip().splitlines()[-1:] or ["{}"]
361
+ try:
362
+ scan_res = json.loads(last_line[0])
363
+ except Exception:
364
+ scan_res = {}
365
+ if not scan_res.get("ok"):
366
+ print(json.dumps({"ok": False, "did": "nothing",
367
+ "reason": f"scan_not_ok: {scan_res.get('state') or 'no_json'}"}))
368
+ return 0
369
+
370
+ scan_path = cfg_path.parent / "last_profile_scan.json"
371
+ if not scan_path.exists():
372
+ print(json.dumps({"ok": False, "did": "nothing", "reason": "no_scan_sidecar"}))
373
+ return 0
374
+ scan = _load_scan(str(scan_path))
375
+
376
+ applied = []
377
+ for proj in eligible:
378
+ ok, reason, examples = _set_examples(proj, scan, args.top, args.min_chars, False)
379
+ if ok:
380
+ applied.append(proj["name"])
381
+ if proj.get("persona") is True:
382
+ section = format_corpus_section(scan, args.top, args.min_chars)
383
+ _upsert_corpus_section(cfg_path.parent / "persona_corpus.txt", section)
384
+ if applied:
385
+ _write_config(cfg_path, cfg)
386
+ print(json.dumps({"ok": True, "did": "backfilled" if applied else "nothing",
387
+ "projects": applied}, ensure_ascii=False))
388
+ return 0
389
+
390
+
391
+ def main(argv) -> int:
392
+ ap = argparse.ArgumentParser(description="Persist scanned top posts/replies as author-voice exemplars")
393
+ sub = ap.add_subparsers(dest="cmd", required=True)
394
+ a = sub.add_parser("apply", help="write voice.examples (+ persona corpus section)")
395
+ a.add_argument("--scan", default=None,
396
+ help="scan_x_profile.py JSON or build_persona gather blob "
397
+ "(default: <config dir>/last_profile_scan.json, written by the scanner)")
398
+ a.add_argument("--project", default=None, help="config.json project name (default: the persona project)")
399
+ a.add_argument("--config", default=None, help="config.json path override (testing)")
400
+ a.add_argument("--top", type=int, default=5, help="max exemplars per surface")
401
+ a.add_argument("--min-chars", type=int, default=40,
402
+ help="skip replies shorter than this when longer ones exist")
403
+ a.add_argument("--force", action="store_true", help="replace hand-written voice.examples")
404
+ a.add_argument("--dry-run", action="store_true", help="print what would be written")
405
+ a.set_defaults(func=cmd_apply)
406
+
407
+ b = sub.add_parser("backfill",
408
+ help="one-shot catch-up on boot: scan + apply for a persona "
409
+ "that predates the exemplar feature")
410
+ b.add_argument("--config", default=None, help="config.json path override (testing)")
411
+ b.add_argument("--top", type=int, default=5)
412
+ b.add_argument("--min-chars", type=int, default=40)
413
+ b.add_argument("--min-hours-between", type=float, default=24.0,
414
+ help="rate limit between scan attempts")
415
+ b.add_argument("--no-scan", action="store_true",
416
+ help="skip the live scan and use the existing last_profile_scan.json")
417
+ b.set_defaults(func=cmd_backfill)
418
+
419
+ args = ap.parse_args(argv)
420
+ return args.func(args)
421
+
422
+
423
+ if __name__ == "__main__":
424
+ raise SystemExit(main(sys.argv[1:]))
@@ -11,9 +11,10 @@
11
11
  # review-queue cards the menu bar shows. Without this merge the cycle's
12
12
  # plan would sit in a /tmp batch file nobody reads.
13
13
  # - draft-only OFF (operator opt-out via `s4l_mode.py draft-only off`,
14
- # promotion-lane cycles only): DRAFT_ONLY=0 — the cycle posts its top-1
15
- # pick autonomously, gated by the rolling virality bar. Persona cycles
16
- # keep making cards either way.
14
+ # single global switch across EVERY lane as of 2026-07-08): DRAFT_ONLY=0
15
+ # — the cycle posts its top-1 pick autonomously; promotion additionally
16
+ # gated by the rolling virality bar. Persona cycles also post
17
+ # autonomously when the flag is off; they no longer stay draft-only.
17
18
  set -uo pipefail
18
19
 
19
20
  # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old plists/tasks still
@@ -1824,17 +1824,30 @@ if [ "${S4L_ACTIVE_LANE:-}" = "personal_brand" ]; then
1824
1824
  DRAFT_DIRECTIVE="Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the PERSONA CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's \`voice\` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').${PERSONA_SKELETON_BAN} This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1825
1825
  fi
1826
1826
 
1827
+ # 2026-07-10 anti-sameness: --no-project-sections strips the multi-project
1828
+ # winner corpus (~400 lines of "Top Posts by Project" + summary table) that
1829
+ # used to ride along in EVERY draft prompt and homogenize drafts. The prep
1830
+ # session now queries per-project winners on demand AFTER routing a
1831
+ # candidate (see PROJECT TOP PERFORMERS section in PREP_PROMPT below).
1827
1832
  if [ -n "$PICKED_STYLE" ]; then
1828
- TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --style "$PICKED_STYLE" 2>/dev/null || echo "(top performers report unavailable)")
1833
+ TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --style "$PICKED_STYLE" --no-project-sections 2>/dev/null || echo "(top performers report unavailable)")
1829
1834
  else
1830
- TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter 2>/dev/null || echo "(top performers report unavailable)")
1835
+ TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --no-project-sections 2>/dev/null || echo "(top performers report unavailable)")
1831
1836
  fi
1832
1837
  if [ -n "$PICKED_STYLE_B" ]; then
1833
- TOP_REPORT_B=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --style "$PICKED_STYLE_B" 2>/dev/null || echo "(top performers report unavailable)")
1838
+ TOP_REPORT_B=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --style "$PICKED_STYLE_B" --no-project-sections 2>/dev/null || echo "(top performers report unavailable)")
1834
1839
  else
1835
- TOP_REPORT_B=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter 2>/dev/null || echo "(top performers report unavailable)")
1840
+ TOP_REPORT_B=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --no-project-sections 2>/dev/null || echo "(top performers report unavailable)")
1836
1841
  fi
1837
1842
 
1843
+ # --- Cross-cycle self-memory (anti-repetition) ------------------------------
1844
+ # 2026-07-10: the prep session never used to see this account's own recent
1845
+ # replies across threads (author_history_block is per-author only), so it
1846
+ # recycled the same openers and sentence skeletons cycle after cycle. This
1847
+ # block is NEGATIVE context, rendered with explicit do-not-imitate rules by
1848
+ # recent_self_posts.py. Empty string on any failure; never blocks the cycle.
1849
+ RECENT_SELF_BLOCK=$(python3 "$REPO_DIR/scripts/recent_self_posts.py" --platform twitter --limit 20 2>/dev/null || echo "")
1850
+
1838
1851
  # --- Generation trace -------------------------------------------------------
1839
1852
  # Snapshot the few-shot context this cycle will feed to Claude — top_performers
1840
1853
  # report, top_queries from Phase 1, supply signal, dud queries — and write to a
@@ -2032,6 +2045,13 @@ $CORPUS_BLOCK
2032
2045
  Each candidate has a 'Project match' field. Use that project unless the thread content clearly better fits another project.
2033
2046
  All project configs: $ALL_PROJECTS_JSON
2034
2047
 
2048
+ ## PROJECT TOP PERFORMERS (query on demand, do NOT skip routing first)
2049
+ The feedback reports below carry a per-style exemplar only; project winners are no longer bulk-injected. AFTER you have decided which project a candidate's draft is for, you MAY pull that project's own recent winners (last 30 days, ranked by real click rate) when you are unsure how this product converts in replies:
2050
+ python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief
2051
+ (PROJECT_NAME exactly as it appears in the candidate's 'Project match' / config.json.) Treat the results as evidence of which CLAIMS and ANGLES landed for that product, never as structural templates: do not copy their sentence shape, opener, or pivot wording. One call per project at most; skip the call entirely for projects you already queried this session.
2052
+
2053
+ $RECENT_SELF_BLOCK
2054
+
2035
2055
  ## DRAFT A: assigned style + feedback from past performance
2036
2056
  $TOP_REPORT
2037
2057