@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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""scripts/recent_self_posts.py — cross-cycle anti-repetition context.
|
|
3
|
+
|
|
4
|
+
Prints a prompt block listing OUR most recent posted replies on a platform
|
|
5
|
+
(across ALL projects), so a drafting session can see what this account
|
|
6
|
+
already sounds like and deliberately diverge. This is the cross-cycle
|
|
7
|
+
complement to author_history_block.py (which is per-author only): before
|
|
8
|
+
2026-07-10 the model never saw its own recent output across threads and
|
|
9
|
+
kept recycling the same openers and sentence skeletons cycle after cycle.
|
|
10
|
+
|
|
11
|
+
The block is explicitly NEGATIVE context ("do not sound like these"), the
|
|
12
|
+
opposite of the top_performers few-shots. Keep it that way: never add
|
|
13
|
+
engagement numbers or any "this one did well" framing here, or the model
|
|
14
|
+
will read it as examples to imitate.
|
|
15
|
+
|
|
16
|
+
Wired into (one callsite):
|
|
17
|
+
- skill/run-twitter-cycle.sh (Phase 2b-prep PREP_PROMPT)
|
|
18
|
+
|
|
19
|
+
CLI:
|
|
20
|
+
python3 scripts/recent_self_posts.py --platform twitter --limit 20
|
|
21
|
+
|
|
22
|
+
Stdout is a ready-to-inject prompt block; EMPTY stdout when there are no
|
|
23
|
+
rows or on any failure (the cycle must never block on this context).
|
|
24
|
+
Stderr carries diagnostics only.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
32
|
+
sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
|
|
33
|
+
from http_api import api_get # noqa: E402
|
|
34
|
+
|
|
35
|
+
# Truncation length per reply. Long enough to expose the opener + skeleton
|
|
36
|
+
# (the parts the model must avoid repeating), short enough that 20 rows stay
|
|
37
|
+
# a small fraction of the prompt.
|
|
38
|
+
SNIPPET_CHARS = 220
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_active_campaign_suffixes():
|
|
42
|
+
"""Best-effort list of active campaign suffix literals to strip.
|
|
43
|
+
|
|
44
|
+
Same contract as author_history_block._load_active_campaign_suffixes:
|
|
45
|
+
the block must never teach the model to echo a campaign suffix (the
|
|
46
|
+
tool layer appends its own copy at post time). On failure returns [].
|
|
47
|
+
"""
|
|
48
|
+
suffixes = []
|
|
49
|
+
try:
|
|
50
|
+
resp = api_get(
|
|
51
|
+
"/api/v1/campaigns",
|
|
52
|
+
query={"status": "active", "has_suffix": "true", "limit": 500},
|
|
53
|
+
)
|
|
54
|
+
rows = ((resp or {}).get("data") or {}).get("campaigns") or []
|
|
55
|
+
for r in rows:
|
|
56
|
+
s = (r.get("suffix") or "").strip()
|
|
57
|
+
if s and s not in suffixes:
|
|
58
|
+
suffixes.append(s)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"[recent_self_posts] suffix load failed: {e!r}", file=sys.stderr)
|
|
61
|
+
return suffixes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _strip_suffixes(text, suffixes):
|
|
65
|
+
"""Trailing-only, idempotent strip (mirrors author_history_block)."""
|
|
66
|
+
if not text or not suffixes:
|
|
67
|
+
return text
|
|
68
|
+
cleaned = text.rstrip()
|
|
69
|
+
changed = True
|
|
70
|
+
while changed:
|
|
71
|
+
changed = False
|
|
72
|
+
for sfx in suffixes:
|
|
73
|
+
if sfx and cleaned.endswith(sfx):
|
|
74
|
+
cleaned = cleaned[: -len(sfx)].rstrip()
|
|
75
|
+
changed = True
|
|
76
|
+
return cleaned
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _snippet(text, suffixes):
|
|
80
|
+
"""One-line, suffix-stripped, truncated rendering of a reply."""
|
|
81
|
+
t = _strip_suffixes((text or "").strip(), suffixes)
|
|
82
|
+
t = " ".join(t.split()) # collapse newlines/runs of whitespace
|
|
83
|
+
if len(t) > SNIPPET_CHARS:
|
|
84
|
+
t = t[: SNIPPET_CHARS - 1].rstrip() + "…"
|
|
85
|
+
return t
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_block(platform, limit):
|
|
89
|
+
"""Return the prompt block string, or "" when nothing to show."""
|
|
90
|
+
resp = api_get(
|
|
91
|
+
"/api/v1/posts",
|
|
92
|
+
query={
|
|
93
|
+
"platform": platform,
|
|
94
|
+
"status": "active",
|
|
95
|
+
"order_by": "posted_at",
|
|
96
|
+
"order_dir": "desc",
|
|
97
|
+
"limit": str(limit),
|
|
98
|
+
# Scope to THIS install's own posts (server filters on the
|
|
99
|
+
# authenticated X-Installation identity). Without this the
|
|
100
|
+
# default read returns the whole fleet's posts and the block
|
|
101
|
+
# would claim another account's replies as "yours"
|
|
102
|
+
# (found 2026-07-10: 8 of 20 rows were another install's).
|
|
103
|
+
"own_install": "true",
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
107
|
+
suffixes = _load_active_campaign_suffixes()
|
|
108
|
+
items = []
|
|
109
|
+
for r in rows:
|
|
110
|
+
snip = _snippet(r.get("our_content"), suffixes)
|
|
111
|
+
if not snip:
|
|
112
|
+
continue
|
|
113
|
+
date = str(r.get("posted_at") or "")[:10]
|
|
114
|
+
proj = r.get("project_name") or "(no project)"
|
|
115
|
+
items.append(f"{len(items) + 1}. [{date} | {proj}] {snip}")
|
|
116
|
+
if len(items) >= limit:
|
|
117
|
+
break
|
|
118
|
+
if not items:
|
|
119
|
+
return ""
|
|
120
|
+
header = (
|
|
121
|
+
"## YOUR RECENT REPLIES (cross-cycle anti-repetition; NEGATIVE examples)\n"
|
|
122
|
+
f"The {len(items)} most recent replies this account posted, all projects. "
|
|
123
|
+
"This is what you ALREADY sound like. It is NOT a list to imitate. "
|
|
124
|
+
"Hard rules for every draft this cycle:\n"
|
|
125
|
+
"- Do NOT reuse any opener below (the first 6-8 words' shape counts, "
|
|
126
|
+
"not just the exact words).\n"
|
|
127
|
+
"- Do NOT reuse their sentence skeletons, rhetorical moves, or pet "
|
|
128
|
+
"phrases (recurring words like 'actually', 'the real X', copula "
|
|
129
|
+
"reframes 'X is the Y').\n"
|
|
130
|
+
"- If a draft you are writing starts to echo any entry below, stop "
|
|
131
|
+
"and rewrite it from a different entry point.\n"
|
|
132
|
+
)
|
|
133
|
+
return header + "\n".join(items)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
parser = argparse.ArgumentParser(
|
|
138
|
+
description="Render the cross-cycle recent-self-replies prompt block")
|
|
139
|
+
parser.add_argument("--platform", default="twitter")
|
|
140
|
+
parser.add_argument("--limit", type=int, default=20)
|
|
141
|
+
args = parser.parse_args()
|
|
142
|
+
try:
|
|
143
|
+
block = build_block(args.platform, max(1, min(args.limit, 50)))
|
|
144
|
+
except Exception as e:
|
|
145
|
+
print(f"[recent_self_posts] failed: {e!r}", file=sys.stderr)
|
|
146
|
+
block = ""
|
|
147
|
+
if block:
|
|
148
|
+
print(block)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
main()
|
|
@@ -29,7 +29,16 @@ Usage:
|
|
|
29
29
|
|
|
30
30
|
Output (stdout, last line is JSON):
|
|
31
31
|
{"ok": true, "handle": "...", "profile": {...}, "posts": [...],
|
|
32
|
-
"comments": [...], "
|
|
32
|
+
"comments": [...], "top_posts": [...], "top_replies": [...],
|
|
33
|
+
"counts": {...}, "grounding_instructions": "..."}
|
|
34
|
+
|
|
35
|
+
top_posts / top_replies are the same items ranked by real engagement
|
|
36
|
+
(likes*3 + retweets*5 + replies*2), each stamped with rank + engagement_score.
|
|
37
|
+
On /with_replies each reply also carries `parent` = {author, text, url} (the
|
|
38
|
+
tweet it replied to, best-effort DOM-adjacent pairing), and the top posts get
|
|
39
|
+
their thread continuation expanded (`thread`: [tweet texts]) by visiting the
|
|
40
|
+
permalink. scripts/voice_exemplars.py turns these into voice.examples +
|
|
41
|
+
persona_corpus.txt exemplars.
|
|
33
42
|
"""
|
|
34
43
|
|
|
35
44
|
from __future__ import annotations
|
|
@@ -182,7 +191,11 @@ def scrape_profile(send) -> dict:
|
|
|
182
191
|
# --------------------------------------------------------------------------- #
|
|
183
192
|
_TIMELINE_JS_TMPL = r"""(function(){
|
|
184
193
|
var ME=%s; // lowercase handle without @
|
|
194
|
+
var WITH_PARENTS=%s; // true only on /with_replies: pair each reply with the
|
|
195
|
+
// other-author article directly above it (same
|
|
196
|
+
// conversation cell renders parent then our reply)
|
|
185
197
|
var out=[];
|
|
198
|
+
var lastOther=null;
|
|
186
199
|
var arts=document.querySelectorAll('article');
|
|
187
200
|
for(var i=0;i<arts.length;i++){
|
|
188
201
|
var art=arts[i];
|
|
@@ -194,10 +207,8 @@ _TIMELINE_JS_TMPL = r"""(function(){
|
|
|
194
207
|
var mm=hh.match(/^\/([A-Za-z0-9_]{1,15})$/);
|
|
195
208
|
if(mm){authorHandle=mm[1].toLowerCase();break;}
|
|
196
209
|
}
|
|
197
|
-
if(authorHandle && authorHandle!==ME) continue; // skip others' posts (reposts/quotes/threads)
|
|
198
210
|
var tEl=art.querySelector('[data-testid="tweetText"]');
|
|
199
211
|
var text=tEl?(tEl.innerText||'').trim():'';
|
|
200
|
-
if(!text) continue;
|
|
201
212
|
// permalink + id
|
|
202
213
|
var url='';var id='';
|
|
203
214
|
var statusLinks=art.querySelectorAll('a[href*="/status/"]');
|
|
@@ -206,6 +217,16 @@ _TIMELINE_JS_TMPL = r"""(function(){
|
|
|
206
217
|
var sm=sh.match(/\/status\/(\d+)/);
|
|
207
218
|
if(sm){id=sm[1];url='https://x.com'+sh.split('?')[0];break;}
|
|
208
219
|
}
|
|
220
|
+
if(authorHandle && authorHandle!==ME){
|
|
221
|
+
// Someone else's article. On /with_replies it is the parent of our next
|
|
222
|
+
// reply in the same conversation cell; remember it. On the posts tab it
|
|
223
|
+
// is a repost/quote we simply skip (WITH_PARENTS=false there).
|
|
224
|
+
if(WITH_PARENTS && text){
|
|
225
|
+
lastOther={author:'@'+authorHandle,text:text.slice(0,500),url:url};
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if(!text) continue;
|
|
209
230
|
// reply? presence of a "Replying to" header in the cell
|
|
210
231
|
var isReply=false, replyTo='';
|
|
211
232
|
var spans=art.querySelectorAll('span,div');
|
|
@@ -226,15 +247,21 @@ _TIMELINE_JS_TMPL = r"""(function(){
|
|
|
226
247
|
var m=al.match(/([\d,]+)/);
|
|
227
248
|
return m?parseInt(m[1].replace(/,/g,''),10):0;
|
|
228
249
|
}
|
|
229
|
-
|
|
230
|
-
likes:metric('like'),replies:metric('reply'),retweets:metric('retweet')}
|
|
250
|
+
var item={text:text,url:url,id:id,is_reply:isReply,reply_to:replyTo,
|
|
251
|
+
likes:metric('like'),replies:metric('reply'),retweets:metric('retweet')};
|
|
252
|
+
if(WITH_PARENTS){
|
|
253
|
+
item.parent=lastOther; // may be null (their own standalone post)
|
|
254
|
+
lastOther=null; // consume: never pair one parent with two replies
|
|
255
|
+
}
|
|
256
|
+
out.push(item);
|
|
231
257
|
}
|
|
232
258
|
return JSON.stringify(out);
|
|
233
259
|
})()"""
|
|
234
260
|
|
|
235
261
|
|
|
236
262
|
def scrape_timeline(send, me: str, want: int, max_scrolls: int = 30,
|
|
237
|
-
exclude_ids: "set | None" = None
|
|
263
|
+
exclude_ids: "set | None" = None,
|
|
264
|
+
capture_parents: bool = False) -> list:
|
|
238
265
|
"""Scroll the current timeline, collecting up to `want` of the user's OWN
|
|
239
266
|
authored articles (in DOM order = newest first). `exclude_ids` drops items
|
|
240
267
|
already captured elsewhere — that's how the comments pass (/with_replies)
|
|
@@ -250,7 +277,8 @@ def scrape_timeline(send, me: str, want: int, max_scrolls: int = 30,
|
|
|
250
277
|
scrolling to the bottom each step to force the next lazy-load batch."""
|
|
251
278
|
seen: dict[str, dict] = {}
|
|
252
279
|
exclude_ids = exclude_ids or set()
|
|
253
|
-
expr = _TIMELINE_JS_TMPL % json.dumps(me.lower())
|
|
280
|
+
expr = _TIMELINE_JS_TMPL % (json.dumps(me.lower()),
|
|
281
|
+
"true" if capture_parents else "false")
|
|
254
282
|
STALL_LIMIT = 4
|
|
255
283
|
stall = 0
|
|
256
284
|
for n in range(max_scrolls):
|
|
@@ -283,6 +311,113 @@ def scrape_timeline(send, me: str, want: int, max_scrolls: int = 30,
|
|
|
283
311
|
return items[:want]
|
|
284
312
|
|
|
285
313
|
|
|
314
|
+
def _own_posted_ids() -> set:
|
|
315
|
+
"""Status ids of everything S4L itself has posted from this install, via
|
|
316
|
+
/api/v1/posts (install-scoped by the X-Installation header). Used to keep
|
|
317
|
+
the bot's own output OUT of the author-voice exemplar pool: on accounts
|
|
318
|
+
where S4L has been active, a recency scan is dominated by S4L drafts, and
|
|
319
|
+
feeding those back as 'the author's voice' is a feedback loop. Best-effort:
|
|
320
|
+
any failure (offline, fresh install, no API) returns an empty set and the
|
|
321
|
+
scan proceeds unfiltered."""
|
|
322
|
+
try:
|
|
323
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
324
|
+
from http_api import api_get # noqa: PLC0415
|
|
325
|
+
import re
|
|
326
|
+
ids: set = set()
|
|
327
|
+
# The route caps limit at 500, so walk the FULL posting history with a
|
|
328
|
+
# forward posted_at cursor (order_dir=asc + since>=). On prolific
|
|
329
|
+
# accounts a single newest-500 page misses older S4L posts that the
|
|
330
|
+
# profile scan still reaches (bit the operator account: 12.8k posted
|
|
331
|
+
# statuses, 2 of 5 picked exemplars were S4L's own). The since filter
|
|
332
|
+
# is inclusive, so overlap rows dedupe via the set; a page whose max
|
|
333
|
+
# posted_at equals the cursor would loop and breaks instead.
|
|
334
|
+
cursor = None
|
|
335
|
+
for _ in range(80): # hard cap 40k statuses
|
|
336
|
+
q = {"platform": "twitter", "has_our_url": "true", "limit": 500,
|
|
337
|
+
"order_by": "posted_at", "order_dir": "asc"}
|
|
338
|
+
if cursor:
|
|
339
|
+
q["since"] = cursor
|
|
340
|
+
resp = api_get("/api/v1/posts", q)
|
|
341
|
+
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
342
|
+
for r in rows:
|
|
343
|
+
m = re.search(r"/status/(\d+)", str(r.get("our_url") or ""))
|
|
344
|
+
if m:
|
|
345
|
+
ids.add(m.group(1))
|
|
346
|
+
if len(rows) < 500:
|
|
347
|
+
break
|
|
348
|
+
page_max = max((r.get("posted_at") or "" for r in rows), default="")
|
|
349
|
+
if not page_max or page_max == cursor:
|
|
350
|
+
break
|
|
351
|
+
cursor = page_max
|
|
352
|
+
return ids
|
|
353
|
+
except Exception as e:
|
|
354
|
+
print(f"[scan_x_profile] own-posts exclusion unavailable: {e}", file=sys.stderr)
|
|
355
|
+
return set()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# --------------------------------------------------------------------------- #
|
|
359
|
+
# Engagement ranking + thread expansion for exemplar extraction.
|
|
360
|
+
# --------------------------------------------------------------------------- #
|
|
361
|
+
def _engagement_score(item: dict) -> int:
|
|
362
|
+
"""Same weighting everywhere exemplars are ranked (voice_exemplars.py
|
|
363
|
+
re-derives it if absent): a retweet is a stronger signal than a like,
|
|
364
|
+
a like stronger than a reply-back."""
|
|
365
|
+
return (int(item.get("likes") or 0) * 3
|
|
366
|
+
+ int(item.get("retweets") or 0) * 5
|
|
367
|
+
+ int(item.get("replies") or 0) * 2)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def rank_top(items: list, n: int = 5) -> list:
|
|
371
|
+
"""Top n items by engagement, stamped with rank + engagement_score.
|
|
372
|
+
Zero-engagement items still rank (small accounts often have nothing else);
|
|
373
|
+
the score on each entry lets downstream decide what to keep."""
|
|
374
|
+
ranked = sorted(items, key=_engagement_score, reverse=True)[:n]
|
|
375
|
+
out = []
|
|
376
|
+
for i, it in enumerate(ranked, 1):
|
|
377
|
+
e = dict(it)
|
|
378
|
+
e["rank"] = i
|
|
379
|
+
e["engagement_score"] = _engagement_score(it)
|
|
380
|
+
out.append(e)
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
_THREAD_JS_TMPL = r"""(function(){
|
|
385
|
+
var ME=%s;
|
|
386
|
+
var out=[];var started=false;
|
|
387
|
+
var arts=document.querySelectorAll('article');
|
|
388
|
+
for(var i=0;i<arts.length;i++){
|
|
389
|
+
var art=arts[i];
|
|
390
|
+
var authorHandle='';
|
|
391
|
+
var links=art.querySelectorAll('a[href^="/"]');
|
|
392
|
+
for(var j=0;j<links.length;j++){
|
|
393
|
+
var hh=links[j].getAttribute('href')||'';
|
|
394
|
+
var mm=hh.match(/^\/([A-Za-z0-9_]{1,15})$/);
|
|
395
|
+
if(mm){authorHandle=mm[1].toLowerCase();break;}
|
|
396
|
+
}
|
|
397
|
+
var tEl=art.querySelector('[data-testid="tweetText"]');
|
|
398
|
+
var text=tEl?(tEl.innerText||'').trim():'';
|
|
399
|
+
if(authorHandle===ME && text){out.push(text);started=true;}
|
|
400
|
+
else if(started){break;} // first non-ME article after the run = end of thread
|
|
401
|
+
}
|
|
402
|
+
return JSON.stringify(out);
|
|
403
|
+
})()"""
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def expand_thread(send, me: str, url: str) -> list:
|
|
407
|
+
"""Visit a post's permalink and return the consecutive run of the user's own
|
|
408
|
+
tweets starting at the focal one ([focal, continuation, ...]). Length 1 =
|
|
409
|
+
not a thread. Bonus: the permalink shows full text, so this also untruncates
|
|
410
|
+
posts the timeline cut with 'Show more'. Best-effort; [] on failure."""
|
|
411
|
+
if not url or not _navigate(send, url, settle=3.5, expect="/status/"):
|
|
412
|
+
return []
|
|
413
|
+
raw = _eval(send, _THREAD_JS_TMPL % json.dumps(me.lower())) or "[]"
|
|
414
|
+
try:
|
|
415
|
+
parts = json.loads(raw)
|
|
416
|
+
except Exception:
|
|
417
|
+
parts = []
|
|
418
|
+
return parts if isinstance(parts, list) else []
|
|
419
|
+
|
|
420
|
+
|
|
286
421
|
GROUNDING_INSTRUCTIONS = (
|
|
287
422
|
"You now have this user's real X profile (bio, original posts, and their own "
|
|
288
423
|
"replies). Use it as GROUND TRUTH to draft their autoposter config fields in "
|
|
@@ -294,9 +429,15 @@ GROUNDING_INSTRUCTIONS = (
|
|
|
294
429
|
"capitalization habits, emoji/punctuation usage, and recurring phrases or tics. "
|
|
295
430
|
"Write the `voice` field so a reply drafted with it would be indistinguishable "
|
|
296
431
|
"from something they'd actually type.\n"
|
|
297
|
-
"3. GOLDEN-RULE EXAMPLES:
|
|
298
|
-
"
|
|
299
|
-
"
|
|
432
|
+
"3. GOLDEN-RULE EXAMPLES: `top_replies` and `top_posts` are already ranked by "
|
|
433
|
+
"REAL engagement (each entry carries likes/retweets/replies, engagement_score, "
|
|
434
|
+
"the parent tweet it replied to, and thread continuations). From them keep up "
|
|
435
|
+
"to 5 replies verbatim as exemplars, skipping throwaway one-liners that show "
|
|
436
|
+
"no voice; on small accounts engagement is sparse, so judge voice quality too. "
|
|
437
|
+
"STORE them in the project's `voice.examples` (every drafter on every platform "
|
|
438
|
+
"mirrors that field), or run "
|
|
439
|
+
"`voice_exemplars.py apply --scan <scan.json> --project <name>` to write "
|
|
440
|
+
"voice.examples + the persona_corpus.txt exemplar section deterministically.\n"
|
|
300
441
|
"4. PHRASE BANK: list the kinds of phrases / openers / sign-offs they reuse, "
|
|
301
442
|
"and any words/claims they clearly AVOID (for `content_guardrails`).\n"
|
|
302
443
|
"5. ICP: infer who they engage with (who they reply to, what communities) to "
|
|
@@ -312,8 +453,12 @@ GROUNDING_INSTRUCTIONS = (
|
|
|
312
453
|
def main() -> int:
|
|
313
454
|
ap = argparse.ArgumentParser()
|
|
314
455
|
ap.add_argument("--handle", default=None, help="@handle to scan (default: live logged-in handle)")
|
|
315
|
-
ap.add_argument("--posts", type=int, default=
|
|
316
|
-
ap.add_argument("--comments", type=int, default=
|
|
456
|
+
ap.add_argument("--posts", type=int, default=60, help="max original posts to collect")
|
|
457
|
+
ap.add_argument("--comments", type=int, default=150, help="max replies/comments to collect")
|
|
458
|
+
ap.add_argument("--top", type=int, default=5, help="how many top posts/replies to rank")
|
|
459
|
+
ap.add_argument("--expand-threads", type=int, default=3,
|
|
460
|
+
help="visit this many top posts' permalinks to capture thread "
|
|
461
|
+
"continuations + full text (0 = off)")
|
|
317
462
|
args = ap.parse_args()
|
|
318
463
|
|
|
319
464
|
if create_connection is None:
|
|
@@ -345,19 +490,48 @@ def main() -> int:
|
|
|
345
490
|
expect=f"/{handle}")
|
|
346
491
|
profile = scrape_profile(send) if on_profile else {}
|
|
347
492
|
|
|
348
|
-
# 2.
|
|
349
|
-
|
|
493
|
+
# 2. Everything S4L itself posted from this install gets excluded from
|
|
494
|
+
# BOTH surfaces (posts and replies): the exemplars must be the
|
|
495
|
+
# human's writing, not the bot's own output echoed back.
|
|
496
|
+
s4l_ids = _own_posted_ids()
|
|
497
|
+
if s4l_ids:
|
|
498
|
+
print(f"[scan_x_profile] excluding {len(s4l_ids)} s4l-posted statuses "
|
|
499
|
+
"from the exemplar pool", file=sys.stderr)
|
|
500
|
+
|
|
501
|
+
# 3. Original posts (current page = posts tab). max_scrolls tracks the
|
|
502
|
+
# requested depth: the scan is programmatic, so scrolling deeper
|
|
503
|
+
# costs only time, and end-of-feed stall detection stops it early
|
|
504
|
+
# on small accounts.
|
|
505
|
+
posts = (scrape_timeline(send, handle, args.posts,
|
|
506
|
+
max_scrolls=max(30, args.posts),
|
|
507
|
+
exclude_ids=s4l_ids)
|
|
508
|
+
if on_profile else [])
|
|
350
509
|
post_ids = {p.get("id") for p in posts if p.get("id")}
|
|
351
510
|
|
|
352
|
-
#
|
|
511
|
+
# 4. Replies / comments = the user's own articles on /with_replies that
|
|
353
512
|
# are NOT among the original posts (set subtraction, not DOM text).
|
|
354
513
|
on_replies = _navigate(send, f"https://x.com/{handle}/with_replies",
|
|
355
514
|
settle=4.0, expect=f"/{handle}/with_replies")
|
|
356
515
|
comments = (
|
|
357
|
-
scrape_timeline(send, handle, args.comments,
|
|
516
|
+
scrape_timeline(send, handle, args.comments,
|
|
517
|
+
max_scrolls=max(30, args.comments),
|
|
518
|
+
exclude_ids=post_ids | s4l_ids,
|
|
519
|
+
capture_parents=True)
|
|
358
520
|
if on_replies else []
|
|
359
521
|
)
|
|
360
522
|
|
|
523
|
+
# 5. Rank both surfaces by real engagement, then expand the top posts'
|
|
524
|
+
# permalinks to capture thread continuations (and untruncated text).
|
|
525
|
+
top_posts = rank_top(posts, args.top)
|
|
526
|
+
top_replies = rank_top(comments, args.top)
|
|
527
|
+
for tp in top_posts[:max(args.expand_threads, 0)]:
|
|
528
|
+
parts = expand_thread(send, handle, tp.get("url") or "")
|
|
529
|
+
if parts:
|
|
530
|
+
if len(parts[0]) > len(tp.get("text") or ""):
|
|
531
|
+
tp["text"] = parts[0] # permalink text is never truncated
|
|
532
|
+
if len(parts) > 1:
|
|
533
|
+
tp["thread"] = parts
|
|
534
|
+
|
|
361
535
|
result = {
|
|
362
536
|
"ok": True,
|
|
363
537
|
"state": "scanned",
|
|
@@ -365,9 +539,28 @@ def main() -> int:
|
|
|
365
539
|
"profile": profile,
|
|
366
540
|
"posts": posts,
|
|
367
541
|
"comments": comments,
|
|
368
|
-
"
|
|
542
|
+
"top_posts": top_posts,
|
|
543
|
+
"top_replies": top_replies,
|
|
544
|
+
"counts": {"posts": len(posts), "comments": len(comments),
|
|
545
|
+
"s4l_posted_excluded": len(s4l_ids)},
|
|
369
546
|
"grounding_instructions": GROUNDING_INSTRUCTIONS,
|
|
370
547
|
}
|
|
548
|
+
|
|
549
|
+
# Persist the corpus beside config.json so the later project-save step
|
|
550
|
+
# (setup writes the project, then voice_exemplars.py auto-applies the
|
|
551
|
+
# exemplars) works without a re-scan. The scan can run BEFORE config.json
|
|
552
|
+
# exists on a fresh onboarding, hence mkdir. Best-effort: the scan is
|
|
553
|
+
# still fully usable from stdout if the write fails.
|
|
554
|
+
try:
|
|
555
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
556
|
+
import s4l_mode
|
|
557
|
+
sidecar = s4l_mode.config_path().parent / "last_profile_scan.json"
|
|
558
|
+
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
|
559
|
+
sidecar.write_text(json.dumps(result, ensure_ascii=False))
|
|
560
|
+
result["scan_file"] = str(sidecar)
|
|
561
|
+
except Exception:
|
|
562
|
+
result["scan_file"] = None
|
|
563
|
+
|
|
371
564
|
print(json.dumps(result, ensure_ascii=False))
|
|
372
565
|
return 0
|
|
373
566
|
except Exception as e:
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import re, os, subprocess, json, hashlib
|
|
2
|
+
from collections import Counter, defaultdict
|
|
3
|
+
from itertools import combinations
|
|
4
|
+
|
|
5
|
+
DATA = os.path.expanduser("~/social-autoposter/mixer/remotion/src/mixer/data.ts")
|
|
6
|
+
PUB = os.path.expanduser("~/social-autoposter/mixer/remotion/public/mixer")
|
|
7
|
+
src = open(DATA).read()
|
|
8
|
+
|
|
9
|
+
# Extract every clipsV2 block and legacy clips arrays referencing tlh-*.mp4
|
|
10
|
+
# Find all "mixer/tlh-...mp4" occurrences grouped per variant via clipsV2:[ ... ] blocks
|
|
11
|
+
sets = []
|
|
12
|
+
for m in re.finditer(r'clipsV2:\s*\[(.*?)\]', src, re.S):
|
|
13
|
+
clips = re.findall(r'mixer/(tlh-[0-9a-z\-]+\.mp4)', m.group(1))
|
|
14
|
+
if clips:
|
|
15
|
+
sets.append(clips)
|
|
16
|
+
# legacy v1: clips arrays inside TLH? lesson-1 uses clips[] with tlh-1..5
|
|
17
|
+
for m in re.finditer(r'clips:\s*\[(.*?)\]', src, re.S):
|
|
18
|
+
clips = re.findall(r'"(tlh-[0-9a-z\-]+\.mp4)"', m.group(1))
|
|
19
|
+
if clips:
|
|
20
|
+
sets.append(clips)
|
|
21
|
+
|
|
22
|
+
def family(fn):
|
|
23
|
+
# tlh-<firstint>-... -> first integer group
|
|
24
|
+
mm = re.match(r'tlh-(\d+)', fn)
|
|
25
|
+
return mm.group(1) if mm else fn
|
|
26
|
+
|
|
27
|
+
usage = Counter()
|
|
28
|
+
cooc = Counter()
|
|
29
|
+
for s in sets:
|
|
30
|
+
for c in set(s):
|
|
31
|
+
usage[c]+=1
|
|
32
|
+
for a,b in combinations(sorted(set(s)),2):
|
|
33
|
+
cooc[(a,b)]+=1
|
|
34
|
+
|
|
35
|
+
# available encoded tlh clips on disk
|
|
36
|
+
avail = sorted([f for f in os.listdir(PUB) if re.match(r'tlh-[0-9a-z\-]+\.mp4$', f)])
|
|
37
|
+
|
|
38
|
+
def probe(fn):
|
|
39
|
+
p = os.path.join(PUB, fn)
|
|
40
|
+
out = subprocess.run(["ffprobe","-v","error","-select_streams","v:0",
|
|
41
|
+
"-show_entries","stream=width,height:format=duration","-of","json",p],
|
|
42
|
+
capture_output=True,text=True).stdout
|
|
43
|
+
j = json.loads(out)
|
|
44
|
+
st = j["stream"][0] if "stream" in j else j["streams"][0]
|
|
45
|
+
dur = float(j["format"]["duration"])
|
|
46
|
+
return st["width"], st["height"], dur
|
|
47
|
+
|
|
48
|
+
def blackframes(fn):
|
|
49
|
+
p = os.path.join(PUB, fn)
|
|
50
|
+
r = subprocess.run(["ffmpeg","-v","error","-i",p,"-vf","blackdetect=d=0.1:pic_th=0.98",
|
|
51
|
+
"-an","-f","null","-"],capture_output=True,text=True)
|
|
52
|
+
return "black_start" in r.stderr
|
|
53
|
+
|
|
54
|
+
def sha(fn):
|
|
55
|
+
return hashlib.sha256(open(os.path.join(PUB,fn),'rb').read()).hexdigest()
|
|
56
|
+
|
|
57
|
+
# Filter avail to valid 1080x1920, dur>=1.75 (holds a 2.0s slot acceptably or exact), non-black
|
|
58
|
+
os.environ["PATH"]="/opt/homebrew/Cellar/ffmpeg/8.1.1/bin:"+os.environ["PATH"]
|
|
59
|
+
valid=[]
|
|
60
|
+
info={}
|
|
61
|
+
for f in avail:
|
|
62
|
+
try:
|
|
63
|
+
w,h,d = probe(f)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
continue
|
|
66
|
+
if (w,h)!=(1080,1920): continue
|
|
67
|
+
info[f]=(w,h,d)
|
|
68
|
+
valid.append(f)
|
|
69
|
+
|
|
70
|
+
print("total prior sets:", len(sets))
|
|
71
|
+
print("valid 1080x1920 clips on disk:", len(valid))
|
|
72
|
+
|
|
73
|
+
# Build candidate 4-sets: distinct families, zero pairwise co-occurrence, low total usage.
|
|
74
|
+
# Rank by (max pairwise cooc, total usage). Verify durations >=1.75 and non-black + distinct sha lazily on the winner.
|
|
75
|
+
from itertools import combinations as comb
|
|
76
|
+
# group valid by family, prefer low-usage clips
|
|
77
|
+
valid_sorted = sorted(valid, key=lambda f:(usage[f], f))
|
|
78
|
+
best=None
|
|
79
|
+
# to keep it tractable, restrict candidate pool to the 40 lowest-usage valid clips across distinct families
|
|
80
|
+
pool = valid_sorted[:60]
|
|
81
|
+
results=[]
|
|
82
|
+
for quad in comb(pool,4):
|
|
83
|
+
fams=[family(f) for f in quad]
|
|
84
|
+
if len(set(fams))!=4: continue
|
|
85
|
+
pairs=list(comb(sorted(quad),2))
|
|
86
|
+
maxco=max(cooc[p] for p in pairs)
|
|
87
|
+
tot=sum(usage[f] for f in quad)
|
|
88
|
+
results.append((maxco,tot,quad))
|
|
89
|
+
results.sort(key=lambda x:(x[0],x[1]))
|
|
90
|
+
print("\ntop 15 candidate quads (maxcooc, totalusage, clips):")
|
|
91
|
+
for maxco,tot,quad in results[:15]:
|
|
92
|
+
print(maxco,tot,list(quad),"durs",[round(info[f][2],3) for f in quad])
|