@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.
- package/mcp/dist/index.js +71 -2
- package/mcp/dist/twitterAuth.js +3 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +111 -11
- package/mcp/menubar/s4l_menubar.py +7 -0
- package/mcp/menubar/s4l_state.py +4 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/build_persona.py +8 -3
- package/scripts/engagement_styles.py +12 -1
- package/scripts/enrich_reply_parents.py +266 -0
- package/scripts/feedback_digest.py +70 -0
- package/scripts/invent_styles.py +245 -0
- package/scripts/recent_self_posts.py +152 -0
- package/scripts/scan_x_profile.py +210 -17
- package/scripts/tlh_pick_248.py +92 -0
- package/scripts/top_performers.py +89 -9
- package/scripts/voice_exemplars.py +461 -0
- package/skill/engage-twitter.sh +9 -0
- package/skill/run-twitter-cycle.sh +51 -4
|
@@ -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
|
-
|
|
392
|
-
|
|
393
|
-
lines.append(
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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:]))
|
package/skill/engage-twitter.sh
CHANGED
|
@@ -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
|
# ═══════════════════════════════════════════════════════
|