@m13v/s4l 1.7.3 → 1.7.4-rc.10

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.
@@ -388,15 +388,17 @@ def format_report(summary, top, bottom, project=None, platform=None,
388
388
  # Projects with zero total_clicks across many posts are the canaries
389
389
  # for "this product/voice combination isn't landing" (the 'General'
390
390
  # bucket in the 7d audit on 2026-05-12: 56 posts, 0 clicks).
391
- lines.append("### Posts per Project per Platform")
392
- for row in summary:
393
- lines.append(
394
- f" {row[0]:<20} {row[1]:<12} {row[2]:>5} posts "
395
- f"avg_clicks={row[5]} avg_cm={row[4]} avg_up={row[3]} "
396
- f"best_clicks={row[8]} best_cm={row[7]} best_up={row[6]} "
397
- f"total_clicks={row[9]}"
398
- )
399
- lines.append("")
391
+ # Empty summary (--no-project-sections) skips the section entirely.
392
+ if summary:
393
+ lines.append("### Posts per Project per Platform")
394
+ for row in summary:
395
+ lines.append(
396
+ f" {row[0]:<20} {row[1]:<12} {row[2]:>5} posts "
397
+ f"avg_clicks={row[5]} avg_cm={row[4]} avg_up={row[3]} "
398
+ f"best_clicks={row[8]} best_cm={row[7]} best_up={row[6]} "
399
+ f"total_clicks={row[9]}"
400
+ )
401
+ lines.append("")
400
402
 
401
403
  # Per-project top performers (when no project filter)
402
404
  if top_by_group:
@@ -425,6 +427,16 @@ def format_report(summary, top, bottom, project=None, platform=None,
425
427
  for p in fallback_top:
426
428
  lines.append(format_post(p, suffix_strip_list=suffix_strip_list))
427
429
  lines.append("")
430
+ elif project:
431
+ # Project filter given, nothing qualified, and no fallback rows
432
+ # either (or the caller suppressed them). Say so explicitly so the
433
+ # on-demand --brief caller sees a definitive answer, not a bare
434
+ # header it might re-query.
435
+ lines.append(
436
+ f"### No {project} posts meeting {threshold_label} in the recency window. "
437
+ "Draft from the thread and the project's config voice; there is no winner to ground on."
438
+ )
439
+ lines.append("")
428
440
 
429
441
  # Bottom posts with failure annotations
430
442
  if bottom:
@@ -540,9 +552,58 @@ def main():
540
552
  "the few-shot exemplar section shows only the matching "
541
553
  "high-scoring posts instead of every style. Summary, "
542
554
  "fallback_top, and top_by_group are not affected."))
555
+ parser.add_argument("--no-project-sections", action="store_true",
556
+ help=("Omit the per-project summary table and the Top "
557
+ "Posts by Project section from the report. Added "
558
+ "2026-07-10 for the cycle orchestrators: the full "
559
+ "multi-project winner corpus is no longer bulk-"
560
+ "injected into every draft prompt (it homogenized "
561
+ "drafts). Instead the drafting session queries "
562
+ "`--project <name> --top 3` on demand AFTER it "
563
+ "has routed a candidate to a project."))
564
+ parser.add_argument("--brief", action="store_true",
565
+ help=("Render ONLY the top-posts list for the given "
566
+ "--project (no style table, no exemplars, no "
567
+ "summary, no bottom posts). This is the lean "
568
+ "on-demand shape the drafting session calls "
569
+ "after routing a candidate to a project."))
570
+ parser.add_argument("--invoked-by", default=None,
571
+ help=("Caller tag recorded in the on-demand invocation "
572
+ "ledger (the drafting prompt passes the cycle's "
573
+ "batch_id). Tracking only; no behavior change."))
543
574
  parser.add_argument("--json", action="store_true", help="Output as JSON")
544
575
  args = parser.parse_args()
545
576
 
577
+ # On-demand invocation ledger (2026-07-10). Any --project call is the
578
+ # on-demand per-project winners lookup the draft prompt offers; record it
579
+ # at the TOOL level (model self-reports are unreliable) so we can measure
580
+ # whether drafting sessions actually use the query. One JSON line per
581
+ # call in $S4L_STATE_DIR/top-performers-invocations.jsonl; the cycle
582
+ # counts lines for its batch_id after prep and logs a
583
+ # [project_top_performers] marker. Best-effort: never block the report.
584
+ if args.project:
585
+ try:
586
+ import datetime
587
+ state_dir = os.environ.get(
588
+ "S4L_STATE_DIR",
589
+ os.path.expanduser("~/.social-autoposter-mcp"))
590
+ os.makedirs(state_dir, exist_ok=True)
591
+ with open(os.path.join(state_dir,
592
+ "top-performers-invocations.jsonl"),
593
+ "a") as fh:
594
+ fh.write(json.dumps({
595
+ "ts": datetime.datetime.now(
596
+ datetime.timezone.utc).isoformat(),
597
+ "project": args.project,
598
+ "platform": args.platform,
599
+ "brief": bool(args.brief),
600
+ "top": args.top,
601
+ "invoked_by": args.invoked_by,
602
+ }) + "\n")
603
+ except Exception as exc:
604
+ print(f"[top_performers] invocation ledger write failed: {exc!r}",
605
+ file=sys.stderr)
606
+
546
607
  (summary, style_perf, top, bottom, fallback_top,
547
608
  top_by_group, top_by_style) = _fetch_report_via_api(
548
609
  platform=args.platform, project=args.project, top=args.top, bottom=args.bottom,
@@ -557,6 +618,25 @@ def main():
557
618
  if row and len(row) > 12 and row[12] in wanted
558
619
  ]
559
620
 
621
+ if args.no_project_sections:
622
+ summary = []
623
+ top_by_group = None
624
+ # Also drop the flat top-posts list: with top_by_group gone,
625
+ # format_report's `elif top:` branch would otherwise render a
626
+ # platform-wide "Top N Posts" block, which is the same shared
627
+ # winner corpus under a different header.
628
+ top = []
629
+ fallback_top = None
630
+
631
+ if args.brief:
632
+ # Lean per-project view: keep `top` (and its fallback message),
633
+ # strip everything else.
634
+ style_perf = []
635
+ top_by_style = []
636
+ summary = []
637
+ top_by_group = None
638
+ bottom = []
639
+
560
640
  if args.json:
561
641
  output = {
562
642
  "summary": [list(row) for row in summary],
@@ -0,0 +1,461 @@
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
+ """One mkdir attempt on the pipelines' twitter-browser lock (same protocol
291
+ as skill/lock.sh, without its queue)."""
292
+ try:
293
+ _BROWSER_LOCK.mkdir()
294
+ except OSError:
295
+ return None
296
+ try:
297
+ (_BROWSER_LOCK / "pid").write_text(f"{os.getpid()}\n")
298
+ (_BROWSER_LOCK / "expires_at").write_text(f"{int(time.time()) + 600}\n")
299
+ except Exception:
300
+ pass
301
+ return _BROWSER_LOCK
302
+
303
+
304
+ def _wait_browser_lock(max_wait_s: float, poll_s: float = 20.0) -> "Path | None":
305
+ """Wait for the twitter-browser lock however long it takes (up to
306
+ max_wait_s). Holds no other lock while waiting, so this cannot deadlock;
307
+ it just queues politely behind cycles / DM runs. Reclaims a stale lock
308
+ (holder pid dead + lease expired), mirroring skill/lock.sh."""
309
+ deadline = time.time() + max_wait_s
310
+ while True:
311
+ lock = _try_browser_lock()
312
+ if lock is not None:
313
+ return lock
314
+ try:
315
+ pid = int((_BROWSER_LOCK / "pid").read_text().strip())
316
+ expires = int((_BROWSER_LOCK / "expires_at").read_text().strip())
317
+ pid_alive = True
318
+ try:
319
+ os.kill(pid, 0)
320
+ except OSError:
321
+ pid_alive = False
322
+ if not pid_alive and time.time() > expires:
323
+ shutil.rmtree(_BROWSER_LOCK, ignore_errors=True)
324
+ continue # retry mkdir immediately
325
+ except Exception:
326
+ pass # lock mid-transition; just poll again
327
+ if time.time() >= deadline:
328
+ return None
329
+ time.sleep(poll_s)
330
+
331
+
332
+ def _release_browser_lock() -> None:
333
+ try:
334
+ pid = (_BROWSER_LOCK / "pid").read_text().strip()
335
+ if pid == str(os.getpid()):
336
+ shutil.rmtree(_BROWSER_LOCK)
337
+ except Exception:
338
+ pass
339
+
340
+
341
+ def cmd_backfill(args) -> int:
342
+ """Called best-effort on MCP boot. If the persona project predates the
343
+ exemplar feature (no voice.examples_scanned_at), run a fresh profile scan
344
+ and apply. Scoped to the PERSONA project only: every install provisions one
345
+ at onboarding, and it is where the author's own voice lives; product
346
+ projects pick exemplars up on their next project_config save instead. The
347
+ corpus write is additive (the marked section is the only thing replaced;
348
+ dictation and hand-added material stay). No cooldown by design (user rule
349
+ 2026-07-10): every boot retries until the scan succeeds; success stamps
350
+ examples_scanned_at, which makes all later boots a cheap no-op. Boots are
351
+ user-triggered (Desktop launch), so the worst case is one scan attempt per
352
+ launch, and concurrent waiters from overlapping boots dedupe via the
353
+ post-lock eligibility re-check."""
354
+ cfg_path = Path(args.config).expanduser() if args.config else s4l_mode.config_path()
355
+ if not cfg_path.exists():
356
+ print(json.dumps({"ok": True, "did": "nothing", "reason": "no_config"}))
357
+ return 0
358
+ cfg = json.loads(cfg_path.read_text())
359
+ personas = [p for p in cfg.get("projects", []) if p.get("persona") is True]
360
+ eligible = [p for p in personas
361
+ if not (isinstance(p.get("voice"), dict) and p["voice"].get("examples_scanned_at"))
362
+ and not _is_hand_written(p)]
363
+ if not eligible:
364
+ print(json.dumps({"ok": True, "did": "nothing", "reason": "already_done_or_hand_written"}))
365
+ return 0
366
+
367
+ if not args.no_scan:
368
+ lock = _wait_browser_lock(args.max_wait_minutes * 60)
369
+ if lock is None:
370
+ # Waited the whole window and never got the browser; the next
371
+ # boot simply tries again.
372
+ print(json.dumps({"ok": True, "did": "nothing", "reason": "browser_busy_timeout"}))
373
+ return 0
374
+ # The wait can be hours; another boot's backfill may have finished
375
+ # meanwhile. Re-read config and re-check before burning a scan.
376
+ cfg = json.loads(cfg_path.read_text())
377
+ personas = [p for p in cfg.get("projects", []) if p.get("persona") is True]
378
+ eligible = [p for p in personas
379
+ if not (isinstance(p.get("voice"), dict) and p["voice"].get("examples_scanned_at"))
380
+ and not _is_hand_written(p)]
381
+ if not eligible:
382
+ _release_browser_lock()
383
+ print(json.dumps({"ok": True, "did": "nothing", "reason": "done_while_waiting"}))
384
+ return 0
385
+ try:
386
+ py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
387
+ # generous: the default depth (60 posts / 150 replies) can scroll
388
+ # for several minutes on prolific accounts
389
+ r = subprocess.run([py, str(HERE / "scan_x_profile.py")],
390
+ capture_output=True, text=True, timeout=1200)
391
+ except Exception as e:
392
+ print(json.dumps({"ok": False, "did": "nothing", "reason": f"scan_failed: {e}"}))
393
+ return 0
394
+ finally:
395
+ _release_browser_lock()
396
+ last_line = (r.stdout or "").strip().splitlines()[-1:] or ["{}"]
397
+ try:
398
+ scan_res = json.loads(last_line[0])
399
+ except Exception:
400
+ scan_res = {}
401
+ if not scan_res.get("ok"):
402
+ print(json.dumps({"ok": False, "did": "nothing",
403
+ "reason": f"scan_not_ok: {scan_res.get('state') or 'no_json'}"}))
404
+ return 0
405
+
406
+ scan_path = cfg_path.parent / "last_profile_scan.json"
407
+ if not scan_path.exists():
408
+ print(json.dumps({"ok": False, "did": "nothing", "reason": "no_scan_sidecar"}))
409
+ return 0
410
+ scan = _load_scan(str(scan_path))
411
+
412
+ applied = []
413
+ for proj in eligible:
414
+ ok, reason, examples = _set_examples(proj, scan, args.top, args.min_chars, False)
415
+ if ok:
416
+ applied.append(proj["name"])
417
+ if proj.get("persona") is True:
418
+ section = format_corpus_section(scan, args.top, args.min_chars)
419
+ _upsert_corpus_section(cfg_path.parent / "persona_corpus.txt", section)
420
+ if applied:
421
+ _write_config(cfg_path, cfg)
422
+ print(json.dumps({"ok": True, "did": "backfilled" if applied else "nothing",
423
+ "projects": applied}, ensure_ascii=False))
424
+ return 0
425
+
426
+
427
+ def main(argv) -> int:
428
+ ap = argparse.ArgumentParser(description="Persist scanned top posts/replies as author-voice exemplars")
429
+ sub = ap.add_subparsers(dest="cmd", required=True)
430
+ a = sub.add_parser("apply", help="write voice.examples (+ persona corpus section)")
431
+ a.add_argument("--scan", default=None,
432
+ help="scan_x_profile.py JSON or build_persona gather blob "
433
+ "(default: <config dir>/last_profile_scan.json, written by the scanner)")
434
+ a.add_argument("--project", default=None, help="config.json project name (default: the persona project)")
435
+ a.add_argument("--config", default=None, help="config.json path override (testing)")
436
+ a.add_argument("--top", type=int, default=5, help="max exemplars per surface")
437
+ a.add_argument("--min-chars", type=int, default=40,
438
+ help="skip replies shorter than this when longer ones exist")
439
+ a.add_argument("--force", action="store_true", help="replace hand-written voice.examples")
440
+ a.add_argument("--dry-run", action="store_true", help="print what would be written")
441
+ a.set_defaults(func=cmd_apply)
442
+
443
+ b = sub.add_parser("backfill",
444
+ help="one-shot catch-up on boot: scan + apply for a persona "
445
+ "that predates the exemplar feature")
446
+ b.add_argument("--config", default=None, help="config.json path override (testing)")
447
+ b.add_argument("--top", type=int, default=5)
448
+ b.add_argument("--min-chars", type=int, default=40)
449
+ b.add_argument("--max-wait-minutes", type=float, default=12 * 60,
450
+ help="how long to wait for the twitter-browser lock before "
451
+ "giving up until the next boot")
452
+ b.add_argument("--no-scan", action="store_true",
453
+ help="skip the live scan and use the existing last_profile_scan.json")
454
+ b.set_defaults(func=cmd_backfill)
455
+
456
+ args = ap.parse_args(argv)
457
+ return args.func(args)
458
+
459
+
460
+ if __name__ == "__main__":
461
+ raise SystemExit(main(sys.argv[1:]))
@@ -68,6 +68,15 @@ python3 "$REPO_DIR/scripts/scan_twitter_mentions_browser.py" --json-file "$NOTIF
68
68
  || log "WARNING: Phase A scan_twitter_mentions_browser.py exited with code $?"
69
69
  rm -f "$NOTIFS_JSON"
70
70
 
71
+ # Phase A2: fill parent-thread linkage on mention-discovered rows. The
72
+ # notifications feed hides the parent tweet id, so scan rows land with
73
+ # mention_id only; this resolves post_id / parent_reply_id / project via
74
+ # fxtwitter HTTP (no browser, no model). Newest rows first, bounded per run.
75
+ log "Phase A2: Enriching parent linkage on mention replies..."
76
+ python3 "$REPO_DIR/scripts/enrich_reply_parents.py" --limit 25 2>&1 \
77
+ | tee -a "$LOG_FILE" \
78
+ || log "WARNING: Phase A2 enrich_reply_parents.py exited with code $?"
79
+
71
80
  # ═══════════════════════════════════════════════════════
72
81
  # PHASE B: Respond to pending Twitter replies
73
82
  # ═══════════════════════════════════════════════════════