@m13v/s4l 1.7.3 → 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.
- package/mcp/dist/index.js +68 -2
- package/mcp/dist/twitterAuth.js +3 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/build_persona.py +8 -3
- package/scripts/engagement_styles.py +12 -1
- package/scripts/invent_styles.py +245 -0
- package/scripts/recent_self_posts.py +146 -0
- package/scripts/scan_x_profile.py +143 -11
- package/scripts/tlh_pick_248.py +92 -0
- package/scripts/top_performers.py +55 -9
- package/scripts/voice_exemplars.py +424 -0
- package/skill/run-twitter-cycle.sh +24 -4
package/mcp/dist/index.js
CHANGED
|
@@ -1394,6 +1394,31 @@ async function seedSearchQueriesForProject(project, rawQueries) {
|
|
|
1394
1394
|
return { note: ` (Search-query seeding skipped — ${e.message}.)`, queries };
|
|
1395
1395
|
}
|
|
1396
1396
|
}
|
|
1397
|
+
// After a project save, persist the profile scan's engagement-ranked top
|
|
1398
|
+
// replies into that project's voice.examples (and the persona_corpus.txt
|
|
1399
|
+
// exemplar section when the project is the persona). Every drafting prompt on
|
|
1400
|
+
// every platform already mirrors voice.examples, so this ONE write feeds them
|
|
1401
|
+
// all. scripts/voice_exemplars.py reads the last_profile_scan.json sidecar
|
|
1402
|
+
// scan_x_profile.py wrote and only quotes the user's own public replies
|
|
1403
|
+
// verbatim (mechanical ranking, no synthesis). Best-effort by design: no scan
|
|
1404
|
+
// yet, no usable replies, or hand-written voice.examples (exit 3, respected)
|
|
1405
|
+
// all return null and never block the save.
|
|
1406
|
+
async function applyScannedVoiceExamples(project) {
|
|
1407
|
+
try {
|
|
1408
|
+
const res = await runPython("scripts/voice_exemplars.py", ["apply", "--project", project], {
|
|
1409
|
+
timeoutMs: 30_000,
|
|
1410
|
+
});
|
|
1411
|
+
const last = res.stdout.trim().split("\n").slice(-1)[0] || "";
|
|
1412
|
+
const parsed = JSON.parse(last);
|
|
1413
|
+
if (parsed.ok && parsed.voice_examples_written) {
|
|
1414
|
+
return `Stored ${parsed.voice_examples_written} of their top-performing real replies (ranked by engagement, with the threads they answered) as voice.examples — every drafter now mirrors them.`;
|
|
1415
|
+
}
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1397
1422
|
// ---- engagement_mode: choose personal-brand vs product (setup-time) --------
|
|
1398
1423
|
// Part of onboarding: AFTER X connect + profile_scan, BEFORE product config, the
|
|
1399
1424
|
// agent asks the user which mode they want and calls this. It persists the mode
|
|
@@ -1603,6 +1628,10 @@ tool("engagement_mode", {
|
|
|
1603
1628
|
return textContent(`Mode saved as ${mode}, but provisioning the persona project failed: ${e?.message || e}. ` +
|
|
1604
1629
|
`Retry engagement_mode action:'set'.`);
|
|
1605
1630
|
}
|
|
1631
|
+
// Persist the profile scan's top-performing real replies as the persona's
|
|
1632
|
+
// voice.examples + the persona_corpus.txt exemplar section (best-effort;
|
|
1633
|
+
// see applyScannedVoiceExamples).
|
|
1634
|
+
const personaExemplarNote = await applyScannedVoiceExamples(personaName);
|
|
1606
1635
|
// Seed the persona's topics into the DB universe the cycle reads (best-effort;
|
|
1607
1636
|
// the cycle's own fail-loud path still reports if topics are missing).
|
|
1608
1637
|
let personaTopicsSeeded = false;
|
|
@@ -1687,6 +1716,7 @@ tool("engagement_mode", {
|
|
|
1687
1716
|
persona_topic_count: personaTopicCount,
|
|
1688
1717
|
persona_query_count: personaQueryCount,
|
|
1689
1718
|
persona_query_note: personaQueryNote || null,
|
|
1719
|
+
persona_voice_examples: personaExemplarNote,
|
|
1690
1720
|
kicker_installed: kickerInstall ? kickerInstall.ok : null,
|
|
1691
1721
|
kicker_detail: kickerInstall ? kickerInstall.detail : null,
|
|
1692
1722
|
onboarding: onboardingSnapshot(),
|
|
@@ -1966,12 +1996,17 @@ tool("project_config", {
|
|
|
1966
1996
|
counts: scan.counts,
|
|
1967
1997
|
posts: scan.posts,
|
|
1968
1998
|
comments: scan.comments,
|
|
1999
|
+
top_posts: scan.top_posts,
|
|
2000
|
+
top_replies: scan.top_replies,
|
|
1969
2001
|
grounding_instructions: scan.grounding_instructions,
|
|
1970
2002
|
website_research_instructions: WEBSITE_RESEARCH_INSTRUCTIONS,
|
|
1971
2003
|
onboarding: onboardingSnapshot(),
|
|
1972
2004
|
next_step: "FOUR steps, in order. FIRST (VOICE, from this scan): read the bio, posts, and comments " +
|
|
1973
2005
|
"as GROUND TRUTH and, per grounding_instructions, extract their profession/identity, " +
|
|
1974
|
-
"voice & vibe (tone, phrasing, casing, tics),
|
|
2006
|
+
"voice & vibe (tone, phrasing, casing, tics), verbatim golden-rule example replies (the " +
|
|
2007
|
+
"scan pre-ranks these by real engagement in top_replies/top_posts, with stats, parent " +
|
|
2008
|
+
"tweets, and thread continuations; persist them into the project's voice.examples so " +
|
|
2009
|
+
"every drafter mirrors them), " +
|
|
1975
2010
|
"a phrase bank + things they avoid, and their icp. The scan is BACKWARD-LOOKING (only what " +
|
|
1976
2011
|
"they already posted) so it is the source for VOICE, not the primary source for topics. " +
|
|
1977
2012
|
"SECOND (the DICTATION interview — this is where TOPICS + grounding corpus come from, do NOT " +
|
|
@@ -2084,6 +2119,13 @@ tool("project_config", {
|
|
|
2084
2119
|
});
|
|
2085
2120
|
}
|
|
2086
2121
|
const result = applySetup(args);
|
|
2122
|
+
// Persist the profile scan's engagement-ranked exemplars into this
|
|
2123
|
+
// project's voice.examples (+ the persona corpus section when the target
|
|
2124
|
+
// is the persona). Reads the last_profile_scan.json sidecar the scanner
|
|
2125
|
+
// wrote; verbatim quotes of the user's own public replies, so no
|
|
2126
|
+
// synthesis happens here. Best-effort: no scan yet or hand-written
|
|
2127
|
+
// examples present (exit 3) are both fine, and never block the save.
|
|
2128
|
+
const exemplarNote = await applyScannedVoiceExamples(result.project);
|
|
2087
2129
|
if (result.persona) {
|
|
2088
2130
|
// no-op on the onboarding ledger; readiness is reported below as usual.
|
|
2089
2131
|
}
|
|
@@ -2181,6 +2223,7 @@ tool("project_config", {
|
|
|
2181
2223
|
kicker_detail: kickerInstall ? kickerInstall.detail : null,
|
|
2182
2224
|
fields_set: result.fields_set,
|
|
2183
2225
|
fields_removed: result.fields_removed,
|
|
2226
|
+
voice_examples: exemplarNote,
|
|
2184
2227
|
config_path: configPath(),
|
|
2185
2228
|
onboarding: onboardingSnapshot(),
|
|
2186
2229
|
note: (result.persona
|
|
@@ -2197,7 +2240,8 @@ tool("project_config", {
|
|
|
2197
2240
|
: `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
|
|
2198
2241
|
`First derive those fields from existing context, profile_scan, and website research, then ` +
|
|
2199
2242
|
`call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
|
|
2200
|
-
advancedNote
|
|
2243
|
+
advancedNote +
|
|
2244
|
+
(exemplarNote ? ` ${exemplarNote}` : ""),
|
|
2201
2245
|
});
|
|
2202
2246
|
}
|
|
2203
2247
|
catch (e) {
|
|
@@ -5266,6 +5310,28 @@ async function main() {
|
|
|
5266
5310
|
void sendStateSnapshot("startup");
|
|
5267
5311
|
const ss = setInterval(() => void sendStateSnapshot("interval"), 15 * 60_000);
|
|
5268
5312
|
ss.unref();
|
|
5313
|
+
// One-shot voice-exemplar catch-up for installs onboarded BEFORE the
|
|
5314
|
+
// exemplar feature: if the persona has no voice.examples_scanned_at, rescan
|
|
5315
|
+
// the connected X profile and store the top-performing replies as
|
|
5316
|
+
// voice.examples + the persona_corpus.txt exemplar section. Additive only
|
|
5317
|
+
// (regenerates just its own marked corpus section; respects hand-written
|
|
5318
|
+
// examples) and self-limiting (marker file rate-limits scan attempts;
|
|
5319
|
+
// BAIL-ON-BUSY on the twitter-browser lock, so it never contends with a
|
|
5320
|
+
// running cycle — it just retries on a later boot). Delayed so boot-time
|
|
5321
|
+
// work (runtime provision, kicker install) settles first.
|
|
5322
|
+
const backfill = setTimeout(() => {
|
|
5323
|
+
if (isPaused())
|
|
5324
|
+
return;
|
|
5325
|
+
void runPython("scripts/voice_exemplars.py", ["backfill"], { timeoutMs: 420_000 })
|
|
5326
|
+
.then((r) => {
|
|
5327
|
+
const last = r.stdout.trim().split("\n").slice(-1)[0] || "";
|
|
5328
|
+
console.error(`[social-autoposter-mcp] voice-exemplars backfill: ${last}`);
|
|
5329
|
+
})
|
|
5330
|
+
.catch((e) => {
|
|
5331
|
+
console.error("[social-autoposter-mcp] voice-exemplars backfill failed:", e?.message || e);
|
|
5332
|
+
});
|
|
5333
|
+
}, 3 * 60_000);
|
|
5334
|
+
backfill.unref();
|
|
5269
5335
|
}
|
|
5270
5336
|
main().catch(async (err) => {
|
|
5271
5337
|
console.error("[social-autoposter-mcp] fatal:", err);
|
package/mcp/dist/twitterAuth.js
CHANGED
|
@@ -60,8 +60,9 @@ export async function xScanProfile(opts) {
|
|
|
60
60
|
args.push("--handle", opts.handle);
|
|
61
61
|
args.push("--posts", String(opts?.posts ?? 20));
|
|
62
62
|
args.push("--comments", String(opts?.comments ?? 50));
|
|
63
|
-
// The scan scrolls two timelines
|
|
64
|
-
|
|
63
|
+
// The scan scrolls two timelines plus visits the top posts' permalinks for
|
|
64
|
+
// thread expansion; give it room but keep it bounded.
|
|
65
|
+
const res = await runPython("scripts/scan_x_profile.py", args, { timeoutMs: 240_000 });
|
|
65
66
|
try {
|
|
66
67
|
return JSON.parse(res.stdout.trim().split("\n").slice(-1).join("\n"));
|
|
67
68
|
}
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.
|
|
5
|
+
"version": "1.7.4-rc.1",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.4-rc.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
package/scripts/build_persona.py
CHANGED
|
@@ -90,9 +90,14 @@ GROUNDING_INSTRUCTIONS = (
|
|
|
90
90
|
" description 2-3 sentences: who this person is as a builder/voice.\n"
|
|
91
91
|
" content_angle one paragraph of concrete, first-hand experience the "
|
|
92
92
|
"persona can speak from (real projects, real numbers, real pain).\n"
|
|
93
|
-
" voice {tone, never[]}: how they actually write (read
|
|
94
|
-
"posts/replies in the x source). Keep the organic rules: first
|
|
95
|
-
"specific, no links, no feature lists, no sales, no em dashes
|
|
93
|
+
" voice {tone, never[], examples[]}: how they actually write (read "
|
|
94
|
+
"their own posts/replies in the x source). Keep the organic rules: first "
|
|
95
|
+
"person, specific, no links, no feature lists, no sales, no em dashes. For "
|
|
96
|
+
"examples[], the x source pre-ranks their best real replies/posts by "
|
|
97
|
+
"engagement (top_replies / top_posts, each with stats, the parent tweet, and "
|
|
98
|
+
"thread continuations): keep up to 5 verbatim, or run "
|
|
99
|
+
"`voice_exemplars.py apply --scan <x-source.json>` after apply to write "
|
|
100
|
+
"voice.examples + the persona_corpus.txt exemplar section deterministically.\n"
|
|
96
101
|
" search_topics ~15 topics they have genuine experience with.\n"
|
|
97
102
|
" content_corpus (OPTIONAL but STRONGLY encouraged) the RAW gathered "
|
|
98
103
|
"corpus as one plain-text block: the persona's actual posts, replies, repo "
|
|
@@ -1034,7 +1034,18 @@ def compute_target_distribution(platform, context="posting"):
|
|
|
1034
1034
|
# mode="invent" and the prompt hands the model the top N as reference
|
|
1035
1035
|
# material to derive a new style from.
|
|
1036
1036
|
|
|
1037
|
-
|
|
1037
|
+
# 2026-07-10: inline invention retired (rate 0). It ran inside the drafting
|
|
1038
|
+
# prep session with the top-performers leaderboard + winner exemplars in
|
|
1039
|
+
# context, so every "new" style was a renamed clone of the winning
|
|
1040
|
+
# agree-then-relocate move; combined with name-only dedup the registry hit
|
|
1041
|
+
# ~938 styles that were mostly one structure. Invention now lives in the
|
|
1042
|
+
# standalone scripts/invent_styles.py job (operator-only launchd
|
|
1043
|
+
# com.m13v.s4l-invent-styles: the registry is global across installs, so a
|
|
1044
|
+
# central daily run replaces per-post rolls). The invent-mode plumbing in
|
|
1045
|
+
# pickers/posters is kept intact: register_style() is what the standalone
|
|
1046
|
+
# job calls, and a nonzero rate here re-enables the inline path if ever
|
|
1047
|
+
# wanted.
|
|
1048
|
+
INVENT_RATE = 0.0 # retired inline roll (was 0.05, ~1 in 20 posts)
|
|
1038
1049
|
CURATED_TOP_N = 5 # size of the invent-mode reference list (top 5 by score)
|
|
1039
1050
|
|
|
1040
1051
|
# Fallback target comment length (chars) for any style that lacks an explicit
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""scripts/invent_styles.py — standalone daily engagement-style invention.
|
|
3
|
+
|
|
4
|
+
Architectural split (2026-07-10, mirrors the 2026-05-28 invent_topics.py
|
|
5
|
+
split): in-cycle style invention (the picker's 5% INVENT_RATE roll) is
|
|
6
|
+
retired. It ran INSIDE the drafting prep session, with the top-performers
|
|
7
|
+
leaderboard and winner exemplars in context, so every "new" style was a
|
|
8
|
+
renamed variant of the currently-winning move (agree-then-relocate /
|
|
9
|
+
concede-then-reverse). Combined with name-only dedup in register_style,
|
|
10
|
+
the registry accumulated hundreds of semantic clones.
|
|
11
|
+
|
|
12
|
+
This job runs OUTSIDE any drafting context, on the operator Mac only:
|
|
13
|
+
|
|
14
|
+
- The registry is GLOBAL across installs (every install reads every
|
|
15
|
+
style), so invention must NOT fan out per install the way topic
|
|
16
|
+
invention does. One central daily run is the correct scope; that is
|
|
17
|
+
the deliberate difference from invent_topics.py's per-install kicker.
|
|
18
|
+
- The prompt carries the existing style universe and explicitly forbids
|
|
19
|
+
the dominant structural family; the ask is a style from an
|
|
20
|
+
UNREPRESENTED family, not a riff on the champion.
|
|
21
|
+
- Post-hoc semantic dedup (token-Jaccard on description+example, plus a
|
|
22
|
+
reframe-family heuristic) rejects near-clones; a rejection re-prompts
|
|
23
|
+
with a grown avoid-list, at most DUPE_RETRIES times.
|
|
24
|
+
- Accepted styles are registered via engagement_styles.register_style
|
|
25
|
+
(kind='model_invented'), same as the retired inline path, so pickers
|
|
26
|
+
see them on their next tick with zero other wiring.
|
|
27
|
+
|
|
28
|
+
Scheduling: launchd com.m13v.s4l-invent-styles (operator Mac, daily).
|
|
29
|
+
Uses run_claude.sh with tag 'invent-styles'; the tag is NOT in
|
|
30
|
+
claude_job.py TAG_TO_TYPE, so it runs the local `claude -p` lane and has
|
|
31
|
+
no dependency on the Desktop queue worker.
|
|
32
|
+
|
|
33
|
+
CLI:
|
|
34
|
+
python3 scripts/invent_styles.py # invent + register 1
|
|
35
|
+
python3 scripts/invent_styles.py --max-new 2
|
|
36
|
+
python3 scripts/invent_styles.py --dry-run # propose, don't register
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import subprocess
|
|
44
|
+
import sys
|
|
45
|
+
|
|
46
|
+
_REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
47
|
+
sys.path.insert(0, os.path.join(_REPO_DIR, "scripts"))
|
|
48
|
+
from engagement_styles import get_all_styles, register_style # noqa: E402
|
|
49
|
+
|
|
50
|
+
_RUN_CLAUDE_SH = os.path.join(_REPO_DIR, "scripts", "run_claude.sh")
|
|
51
|
+
SCRIPT_TAG = "invent-styles"
|
|
52
|
+
CALL_TIMEOUT_SEC = 420
|
|
53
|
+
DUPE_RETRIES = 3
|
|
54
|
+
SIMILARITY_THRESHOLD = 0.5 # Jaccard on description+example tokens
|
|
55
|
+
|
|
56
|
+
# Heuristic markers of the saturated agree-then-relocate/reframe family.
|
|
57
|
+
# A proposal whose description/example leans on these is a clone of the
|
|
58
|
+
# dominant move no matter how novel its name is; reject like a dupe.
|
|
59
|
+
_REFRAME_MARKERS = re.compile(
|
|
60
|
+
r"\b(reframe|relocat\w+|the real (work|question|problem|cost|part|win)"
|
|
61
|
+
r"|hidden (cost|part|work|meter)|easy (part|half)|hard(er)? (part|half)"
|
|
62
|
+
r"|was never (the|about)|nobody (mentions|talks about|tracks|measures)"
|
|
63
|
+
r"|isn.t the .{0,30}it.s|concede)\b",
|
|
64
|
+
re.I,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tokens(text):
|
|
69
|
+
return set(re.findall(r"[a-z0-9]+", (text or "").lower()))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _jaccard(a, b):
|
|
73
|
+
ta, tb = _tokens(a), _tokens(b)
|
|
74
|
+
if not ta or not tb:
|
|
75
|
+
return 0.0
|
|
76
|
+
return len(ta & tb) / len(ta | tb)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _style_text(entry):
|
|
80
|
+
return f"{entry.get('description') or ''} {entry.get('example') or ''}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _find_near_dupe(proposal, universe):
|
|
84
|
+
"""Return (reason, existing_name) when the proposal is a clone; else None."""
|
|
85
|
+
name = proposal["name"]
|
|
86
|
+
if name in universe:
|
|
87
|
+
return ("name_exists", name)
|
|
88
|
+
ptext = _style_text(proposal)
|
|
89
|
+
if _REFRAME_MARKERS.search(f"{proposal.get('description','')} {name}"):
|
|
90
|
+
return ("reframe_family", "(dominant agree-then-relocate family)")
|
|
91
|
+
best_name, best_sim = None, 0.0
|
|
92
|
+
for ename, entry in universe.items():
|
|
93
|
+
sim = _jaccard(ptext, _style_text(entry))
|
|
94
|
+
if sim > best_sim:
|
|
95
|
+
best_name, best_sim = ename, sim
|
|
96
|
+
if best_sim >= SIMILARITY_THRESHOLD:
|
|
97
|
+
return (f"jaccard={best_sim:.2f}", best_name)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_prompt(universe, avoid):
|
|
102
|
+
names = sorted(universe.keys())
|
|
103
|
+
# Detail only a bounded sample (prompt-size guard): the seeds plus the
|
|
104
|
+
# most recently registered rows carry enough signal about what already
|
|
105
|
+
# exists; the full NAME list covers the rest for the model's self-check.
|
|
106
|
+
detailed = []
|
|
107
|
+
for name in names:
|
|
108
|
+
e = universe[name]
|
|
109
|
+
if e.get("kind") == "seed" or len(detailed) < 40:
|
|
110
|
+
desc = " ".join((e.get("description") or "").split())[:180]
|
|
111
|
+
detailed.append(f"- {name}: {desc}")
|
|
112
|
+
if len(detailed) >= 60:
|
|
113
|
+
break
|
|
114
|
+
avoid_block = ""
|
|
115
|
+
if avoid:
|
|
116
|
+
avoid_block = (
|
|
117
|
+
"\nAlready proposed and REJECTED this run (do not resubmit or "
|
|
118
|
+
"paraphrase): " + ", ".join(avoid) + "\n"
|
|
119
|
+
)
|
|
120
|
+
return f"""You maintain the engagement-style registry for a social reply system. A style is a named rhetorical TEMPLATE (description + one example reply) that drafters follow when writing short replies on X/Twitter and Reddit.
|
|
121
|
+
|
|
122
|
+
PROBLEM: the registry is saturated with ONE structural family: agree-then-relocate (concede the surface point, move the spotlight to the hidden/harder/unmeasured part, often "X is easy, Y is the real work" or "X was never the point, Y is"). Do NOT invent another member of that family, however disguised.
|
|
123
|
+
|
|
124
|
+
EXISTING STYLE NAMES ({len(names)} total):
|
|
125
|
+
{", ".join(names)}
|
|
126
|
+
|
|
127
|
+
REPRESENTATIVE DETAILS (sample):
|
|
128
|
+
{chr(10).join(detailed)}
|
|
129
|
+
{avoid_block}
|
|
130
|
+
TASK: invent exactly ONE genuinely new style from a structural family that is missing or rare above. Families worth mining (pick ONE, or another you identify): direct answer with zero framing; first-person confession of a specific failure; pure curious question with no thesis; dry understatement one-liner; enthusiastic cosign with one concrete addition; flat disagreement stated plainly without conceding anything first; a tiny numbered checklist; a vivid analogy that does NOT end in a lesson; deadpan humor riffing on the thread's wording.
|
|
131
|
+
|
|
132
|
+
Rules:
|
|
133
|
+
- The example must read like a real human reply (lowercase ok), 40-220 chars, NO links, NO product names.
|
|
134
|
+
- The style must be usable across many products and threads, not thread-specific.
|
|
135
|
+
- Your answer will be dedup-checked by token similarity against every existing style's description+example; if you cannot find a genuinely different move, return the saturation envelope instead of forcing a paraphrase.
|
|
136
|
+
|
|
137
|
+
Answer with ONLY one JSON object, no prose, in one of these two shapes:
|
|
138
|
+
{{"name": "snake_case_name", "description": "...", "example": "...", "why_existing_didnt_fit": "...", "target_chars": <int, the length the example is>}}
|
|
139
|
+
{{"saturated": true, "reason": "..."}}"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def call_claude(prompt):
|
|
143
|
+
proc = subprocess.run(
|
|
144
|
+
["bash", _RUN_CLAUDE_SH, SCRIPT_TAG, "-p", "--output-format", "json",
|
|
145
|
+
prompt],
|
|
146
|
+
text=True, capture_output=True, timeout=CALL_TIMEOUT_SEC,
|
|
147
|
+
)
|
|
148
|
+
if proc.returncode == 79:
|
|
149
|
+
raise SystemExit("[invent_styles] provider blocked (exit 79); skipping run")
|
|
150
|
+
if proc.returncode != 0:
|
|
151
|
+
raise SystemExit(
|
|
152
|
+
f"[invent_styles] run_claude.sh exited {proc.returncode}: "
|
|
153
|
+
f"{(proc.stderr or '')[:400]}")
|
|
154
|
+
try:
|
|
155
|
+
envelope = json.loads(proc.stdout)
|
|
156
|
+
text = envelope.get("result") or ""
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
text = proc.stdout
|
|
159
|
+
return text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _extract_json(text):
|
|
163
|
+
"""Pull the first parseable JSON object out of model output."""
|
|
164
|
+
text = (text or "").strip()
|
|
165
|
+
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
|
|
166
|
+
if fence:
|
|
167
|
+
text = fence.group(1)
|
|
168
|
+
start = text.find("{")
|
|
169
|
+
while start != -1:
|
|
170
|
+
for end in range(len(text), start, -1):
|
|
171
|
+
try:
|
|
172
|
+
obj = json.loads(text[start:end])
|
|
173
|
+
if isinstance(obj, dict):
|
|
174
|
+
return obj
|
|
175
|
+
except json.JSONDecodeError:
|
|
176
|
+
continue
|
|
177
|
+
start = text.find("{", start + 1)
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main():
|
|
182
|
+
parser = argparse.ArgumentParser(description="Standalone style invention job")
|
|
183
|
+
parser.add_argument("--max-new", type=int, default=1,
|
|
184
|
+
help="How many styles to invent this run (default 1)")
|
|
185
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
186
|
+
help="Propose and dedup-check but do not register")
|
|
187
|
+
args = parser.parse_args()
|
|
188
|
+
|
|
189
|
+
registered = 0
|
|
190
|
+
for slot in range(max(1, args.max_new)):
|
|
191
|
+
universe = get_all_styles()
|
|
192
|
+
avoid = []
|
|
193
|
+
accepted = None
|
|
194
|
+
for attempt in range(1 + DUPE_RETRIES):
|
|
195
|
+
raw = call_claude(build_prompt(universe, avoid))
|
|
196
|
+
obj = _extract_json(raw)
|
|
197
|
+
if not obj:
|
|
198
|
+
print(f"[invent_styles] slot={slot} attempt={attempt} "
|
|
199
|
+
f"unparseable output: {raw[:200]!r}", file=sys.stderr)
|
|
200
|
+
continue
|
|
201
|
+
if obj.get("saturated"):
|
|
202
|
+
print(f"[invent_styles] slot={slot} model reports saturation: "
|
|
203
|
+
f"{obj.get('reason', '')[:200]}", file=sys.stderr)
|
|
204
|
+
break
|
|
205
|
+
name = str(obj.get("name") or "").strip()
|
|
206
|
+
if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
|
|
207
|
+
print(f"[invent_styles] slot={slot} bad name {name!r}; retrying",
|
|
208
|
+
file=sys.stderr)
|
|
209
|
+
avoid.append(name or "(unnamed)")
|
|
210
|
+
continue
|
|
211
|
+
dupe = _find_near_dupe({**obj, "name": name}, universe)
|
|
212
|
+
if dupe:
|
|
213
|
+
reason, existing = dupe
|
|
214
|
+
print(f"[invent_styles] slot={slot} rejected {name!r}: {reason} "
|
|
215
|
+
f"vs {existing}", file=sys.stderr)
|
|
216
|
+
avoid.append(name)
|
|
217
|
+
continue
|
|
218
|
+
accepted = {**obj, "name": name}
|
|
219
|
+
break
|
|
220
|
+
if not accepted:
|
|
221
|
+
continue
|
|
222
|
+
if args.dry_run:
|
|
223
|
+
print(json.dumps({"dry_run": True, **accepted}, indent=2))
|
|
224
|
+
continue
|
|
225
|
+
status, entry = register_style(
|
|
226
|
+
accepted["name"],
|
|
227
|
+
{
|
|
228
|
+
"description": accepted.get("description", ""),
|
|
229
|
+
"example": accepted.get("example", ""),
|
|
230
|
+
"why_existing_didnt_fit": accepted.get("why_existing_didnt_fit", ""),
|
|
231
|
+
"target_chars": accepted.get("target_chars"),
|
|
232
|
+
},
|
|
233
|
+
source_post={"platform": "invent_styles_job", "model": "invent_styles"},
|
|
234
|
+
)
|
|
235
|
+
print(f"[invent_styles] slot={slot} register status={status} "
|
|
236
|
+
f"name={accepted['name']}", file=sys.stderr)
|
|
237
|
+
if status == "new":
|
|
238
|
+
registered += 1
|
|
239
|
+
print(json.dumps({"registered": accepted["name"],
|
|
240
|
+
"description": accepted.get("description", "")}))
|
|
241
|
+
print(f"[invent_styles] done: registered={registered}", file=sys.stderr)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
main()
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
},
|
|
99
|
+
)
|
|
100
|
+
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
101
|
+
suffixes = _load_active_campaign_suffixes()
|
|
102
|
+
items = []
|
|
103
|
+
for r in rows:
|
|
104
|
+
snip = _snippet(r.get("our_content"), suffixes)
|
|
105
|
+
if not snip:
|
|
106
|
+
continue
|
|
107
|
+
date = str(r.get("posted_at") or "")[:10]
|
|
108
|
+
proj = r.get("project_name") or "(no project)"
|
|
109
|
+
items.append(f"{len(items) + 1}. [{date} | {proj}] {snip}")
|
|
110
|
+
if len(items) >= limit:
|
|
111
|
+
break
|
|
112
|
+
if not items:
|
|
113
|
+
return ""
|
|
114
|
+
header = (
|
|
115
|
+
"## YOUR RECENT REPLIES (cross-cycle anti-repetition; NEGATIVE examples)\n"
|
|
116
|
+
f"The {len(items)} most recent replies this account posted, all projects. "
|
|
117
|
+
"This is what you ALREADY sound like. It is NOT a list to imitate. "
|
|
118
|
+
"Hard rules for every draft this cycle:\n"
|
|
119
|
+
"- Do NOT reuse any opener below (the first 6-8 words' shape counts, "
|
|
120
|
+
"not just the exact words).\n"
|
|
121
|
+
"- Do NOT reuse their sentence skeletons, rhetorical moves, or pet "
|
|
122
|
+
"phrases (recurring words like 'actually', 'the real X', copula "
|
|
123
|
+
"reframes 'X is the Y').\n"
|
|
124
|
+
"- If a draft you are writing starts to echo any entry below, stop "
|
|
125
|
+
"and rewrite it from a different entry point.\n"
|
|
126
|
+
)
|
|
127
|
+
return header + "\n".join(items)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main():
|
|
131
|
+
parser = argparse.ArgumentParser(
|
|
132
|
+
description="Render the cross-cycle recent-self-replies prompt block")
|
|
133
|
+
parser.add_argument("--platform", default="twitter")
|
|
134
|
+
parser.add_argument("--limit", type=int, default=20)
|
|
135
|
+
args = parser.parse_args()
|
|
136
|
+
try:
|
|
137
|
+
block = build_block(args.platform, max(1, min(args.limit, 50)))
|
|
138
|
+
except Exception as e:
|
|
139
|
+
print(f"[recent_self_posts] failed: {e!r}", file=sys.stderr)
|
|
140
|
+
block = ""
|
|
141
|
+
if block:
|
|
142
|
+
print(block)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|