social-autoposter 1.6.178 → 1.6.180
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 +110 -63
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +10 -0
- package/mcp/menubar/s4l_state.py +44 -7
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/build_persona.py +26 -2
- package/scripts/memory_snapshot.py +6 -1
- package/scripts/saps_mode.py +5 -0
- package/scripts/twitter_gen_links.py +8 -0
- package/skill/run-twitter-cycle.sh +66 -14
package/mcp/dist/index.js
CHANGED
|
@@ -1030,12 +1030,57 @@ const WEBSITE_RESEARCH_INSTRUCTIONS = "PRODUCT RESEARCH (do this before saving t
|
|
|
1030
1030
|
"SAME call — YOU are the model, so do the expansion in-session; it seeds directly with no `claude -p`. " +
|
|
1031
1031
|
"If the site is thin or unreachable, use only supported facts and leave optional detail conservative; " +
|
|
1032
1032
|
"ask the user only if a required field is genuinely unknowable.";
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1033
|
+
async function seedSearchQueriesForProject(project, rawQueries) {
|
|
1034
|
+
const agentQueries = normalizeStringList(rawQueries) ?? [];
|
|
1035
|
+
let queries = [];
|
|
1036
|
+
if (!agentQueries.length) {
|
|
1037
|
+
return {
|
|
1038
|
+
note: " (No search_queries supplied, so the cycle will run off the seeded topics one at a time. " +
|
|
1039
|
+
"To fan out, re-run with a search_queries array of ~30 X search strings you expand from these " +
|
|
1040
|
+
"topics — it seeds them directly, no claude CLI.)",
|
|
1041
|
+
queries,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
const qfile = path.join(os.tmpdir(), `saps-queries-${project}-${Date.now()}.json`);
|
|
1046
|
+
fs.writeFileSync(qfile, JSON.stringify({ queries: agentQueries.map((q) => ({ query: q, topic: "" })) }));
|
|
1047
|
+
const qseed = await runPython("scripts/seed_search_queries.py", ["--project", project, "--queries-json", qfile, "--supply-test", "auto", "--emit-json"], { timeoutMs: 600_000 });
|
|
1048
|
+
try {
|
|
1049
|
+
fs.unlinkSync(qfile);
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
/* best-effort cleanup */
|
|
1053
|
+
}
|
|
1054
|
+
const qm = /seeded=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(qseed.stdout);
|
|
1055
|
+
const qjson = qseed.stdout.split("===QUERIES_JSON===")[1];
|
|
1056
|
+
if (qjson) {
|
|
1057
|
+
try {
|
|
1058
|
+
queries = (JSON.parse(qjson.trim()).queries ?? []);
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
/* leave empty; count note still informs the user */
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (qseed.code === 0 && qm) {
|
|
1065
|
+
const n = queries.length || Number(qm[1]);
|
|
1066
|
+
return {
|
|
1067
|
+
note: ` Seeded ${n} search quer${n === 1 ? "y" : "ies"} so the cycle can fan out instead of running a single query.`,
|
|
1068
|
+
queries,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
if (qseed.code !== 0) {
|
|
1072
|
+
const qtail = (qseed.stderr || qseed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
1073
|
+
return {
|
|
1074
|
+
note: ` (Search queries not seeded yet — ${qtail}. The cycle still runs off the seeded topics.)`,
|
|
1075
|
+
queries,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
return { note: "", queries };
|
|
1079
|
+
}
|
|
1080
|
+
catch (e) {
|
|
1081
|
+
return { note: ` (Search-query seeding skipped — ${e.message}.)`, queries };
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1039
1084
|
// ---- engagement_mode: choose personal-brand vs product (setup-time) --------
|
|
1040
1085
|
// Part of onboarding: AFTER X connect + profile_scan, BEFORE product config, the
|
|
1041
1086
|
// agent asks the user which mode they want and calls this. It persists the mode
|
|
@@ -1051,9 +1096,11 @@ tool("engagement_mode", {
|
|
|
1051
1096
|
"is scanned, personal-brand is already the default, so ASK the user the ONE question: do they " +
|
|
1052
1097
|
"ALSO want to promote a product? Then call action:'set' with personal_brand:true and " +
|
|
1053
1098
|
"promotion:true|false. Pass the voice/description/topics you extracted from the profile scan so " +
|
|
1054
|
-
"the persona is grounded in who they actually are
|
|
1055
|
-
"
|
|
1056
|
-
"
|
|
1099
|
+
"the persona is grounded in who they actually are, AND expand those topics into a search_queries " +
|
|
1100
|
+
"array of ~30 concrete X advanced-search strings in the SAME call (identical to project_config) " +
|
|
1101
|
+
"so the personal-brand cycle has a real query bank on day one instead of running one crude " +
|
|
1102
|
+
"topic-as-query. If they want promotion too, continue to configure the product project with " +
|
|
1103
|
+
"project_config afterward. The user flips either lane any time from the menu-bar checkmarks.",
|
|
1057
1104
|
inputSchema: {
|
|
1058
1105
|
action: z
|
|
1059
1106
|
.enum(["get", "set", "toggle"])
|
|
@@ -1092,6 +1139,14 @@ tool("engagement_mode", {
|
|
|
1092
1139
|
.union([z.array(z.string()), z.string()])
|
|
1093
1140
|
.optional()
|
|
1094
1141
|
.describe("~15 topics the persona has genuine experience with, drawn from the scan."),
|
|
1142
|
+
search_queries: z
|
|
1143
|
+
.union([z.array(z.string()), z.string()])
|
|
1144
|
+
.optional()
|
|
1145
|
+
.describe("Cold-start X search-query bank YOU expand from search_topics, in THIS same call — same " +
|
|
1146
|
+
"as project_config. Fan each persona topic into a few concrete X advanced-search strings " +
|
|
1147
|
+
"(aim ~30 total, e.g. 'mac menu bar app -filter:replies', 'screen recording lang:en') so " +
|
|
1148
|
+
"the personal-brand cycle fans out instead of running one crude topic-as-query. Seeded " +
|
|
1149
|
+
"directly with NO `claude -p`. Without it the persona bank is empty on day one."),
|
|
1095
1150
|
},
|
|
1096
1151
|
}, async (args) => {
|
|
1097
1152
|
const action = args.action || "get";
|
|
@@ -1183,6 +1238,17 @@ tool("engagement_mode", {
|
|
|
1183
1238
|
personaTopicCount = m ? Number(m[1]) : 0;
|
|
1184
1239
|
personaTopicsSeeded = true;
|
|
1185
1240
|
}
|
|
1241
|
+
// Seed the persona's search QUERIES too — identical to the product path
|
|
1242
|
+
// (project_config). Personal-brand-only setups used to seed topics but never
|
|
1243
|
+
// queries, so their Phase 1 bank was empty and the cycle ran one crude
|
|
1244
|
+
// topic-as-query (Karol, 2026-06-30). Only after the topic seed succeeds.
|
|
1245
|
+
let personaQueryCount = 0;
|
|
1246
|
+
let personaQueryNote = "";
|
|
1247
|
+
if (personaTopicsSeeded) {
|
|
1248
|
+
const qr = await seedSearchQueriesForProject(personaName, args.search_queries);
|
|
1249
|
+
personaQueryCount = qr.queries.length;
|
|
1250
|
+
personaQueryNote = qr.note;
|
|
1251
|
+
}
|
|
1186
1252
|
completeOnboardingMilestone("mode_chosen", { personal_brand: personalBrand, promotion, persona: personaName });
|
|
1187
1253
|
// Personal-brand-only is a first-class setup path: the persona is the draftable
|
|
1188
1254
|
// project, so seeding its topics IS the topics_seeded milestone. Without this the
|
|
@@ -1220,8 +1286,10 @@ tool("engagement_mode", {
|
|
|
1220
1286
|
"is provisioned + topic-seeded. "
|
|
1221
1287
|
: "Product promotion is on and the persona is provisioned. ") +
|
|
1222
1288
|
"NOW CONTINUE SETUP: configure the product project with project_config (research the product " +
|
|
1223
|
-
"site and fill description, icp, voice, search_topics)."
|
|
1224
|
-
:
|
|
1289
|
+
"site and fill description, icp, voice, search_topics, search_queries)."
|
|
1290
|
+
: (personaQueryCount > 0
|
|
1291
|
+
? `Personal-brand lane is on (the default); the persona is provisioned, topic-seeded, and ${personaQueryCount} search quer${personaQueryCount === 1 ? "y" : "ies"} seeded, so there `
|
|
1292
|
+
: "Personal-brand lane is on (the default); the persona is provisioned + topic-seeded (but NO search_queries were supplied, so it will run one topic-as-query at a time — re-call engagement_mode action:'set' with a search_queries array of ~30 X search strings expanded from the persona topics to fan out). There ") +
|
|
1225
1293
|
"is nothing more to configure (no product project is needed). NOW SCHEDULE THE AUTOPILOT: call " +
|
|
1226
1294
|
"queue_setup and create each returned task with create_scheduled_task (prompt verbatim; " +
|
|
1227
1295
|
"'already exists' is fine), then call the dashboard tool to confirm the schedule is firing. " +
|
|
@@ -1234,6 +1302,8 @@ tool("engagement_mode", {
|
|
|
1234
1302
|
persona_created: personaCreated,
|
|
1235
1303
|
persona_topics_seeded: personaTopicsSeeded,
|
|
1236
1304
|
persona_topic_count: personaTopicCount,
|
|
1305
|
+
persona_query_count: personaQueryCount,
|
|
1306
|
+
persona_query_note: personaQueryNote || null,
|
|
1237
1307
|
kicker_installed: kickerInstall ? kickerInstall.ok : null,
|
|
1238
1308
|
kicker_detail: kickerInstall ? kickerInstall.detail : null,
|
|
1239
1309
|
onboarding: onboardingSnapshot(),
|
|
@@ -1616,59 +1686,34 @@ tool("project_config", {
|
|
|
1616
1686
|
blockOnboardingMilestone("topics_seeded", "topic_seed_failed", tail, { exit_code: seed.code });
|
|
1617
1687
|
seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. The autopilot will report clearly if topics are missing.)`;
|
|
1618
1688
|
}
|
|
1619
|
-
// Cold-start QUERY supply
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
// installed in the Desktop / .mcpb lane and was the FileNotFoundError
|
|
1629
|
-
// users hit). If the agent didn't supply queries, we skip expansion
|
|
1630
|
-
// entirely — the topic-as-query fallback still runs, just narrower — and
|
|
1631
|
-
// nudge the agent to re-run with search_queries. (2026-06-19)
|
|
1632
|
-
// Accept search_queries as a real array OR a stringified JSON array /
|
|
1633
|
-
// comma list — the model often passes the latter, and the old
|
|
1634
|
-
// Array.isArray-only gate silently dropped it, leaving the query bank
|
|
1635
|
-
// empty on a fresh install (Karol, 2026-06-30).
|
|
1636
|
-
const agentQueries = normalizeStringList(args.search_queries) ?? [];
|
|
1637
|
-
if (seed.code === 0 && agentQueries.length) {
|
|
1638
|
-
try {
|
|
1639
|
-
const qfile = path.join(os.tmpdir(), `saps-queries-${result.project}-${Date.now()}.json`);
|
|
1640
|
-
fs.writeFileSync(qfile, JSON.stringify({ queries: agentQueries.map((q) => ({ query: q, topic: "" })) }));
|
|
1641
|
-
const qseed = await runPython("scripts/seed_search_queries.py", ["--project", result.project, "--queries-json", qfile,
|
|
1642
|
-
"--supply-test", "auto", "--emit-json"], { timeoutMs: 600_000 });
|
|
1643
|
-
try {
|
|
1644
|
-
fs.unlinkSync(qfile);
|
|
1645
|
-
}
|
|
1646
|
-
catch { /* best-effort cleanup */ }
|
|
1647
|
-
const qm = /seeded=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(qseed.stdout);
|
|
1648
|
-
const qjson = qseed.stdout.split("===QUERIES_JSON===")[1];
|
|
1649
|
-
if (qjson) {
|
|
1650
|
-
try {
|
|
1651
|
-
searchQueries = (JSON.parse(qjson.trim()).queries ?? []);
|
|
1652
|
-
}
|
|
1653
|
-
catch {
|
|
1654
|
-
/* leave empty; count note still informs the user */
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
if (qseed.code === 0 && qm) {
|
|
1658
|
-
const n = searchQueries.length || Number(qm[1]);
|
|
1659
|
-
seedNote += ` Seeded ${n} search quer${n === 1 ? "y" : "ies"} so the cycle can fan out instead of running a single query.`;
|
|
1660
|
-
}
|
|
1661
|
-
else if (qseed.code !== 0) {
|
|
1662
|
-
const qtail = (qseed.stderr || qseed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
1663
|
-
seedNote += ` (Search queries not seeded yet — ${qtail}. The cycle still runs off the seeded topics.)`;
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
catch (e) {
|
|
1667
|
-
seedNote += ` (Search-query seeding skipped — ${e.message}.)`;
|
|
1668
|
-
}
|
|
1689
|
+
// Cold-start QUERY supply (shared with the persona/engagement_mode path
|
|
1690
|
+
// via seedSearchQueriesForProject): fan the agent-supplied search_queries
|
|
1691
|
+
// into project_search_queries so the Phase 1 bank fans out on day one
|
|
1692
|
+
// instead of running ONE crude topic-as-query. Only after the topic seed
|
|
1693
|
+
// succeeds, matching the persona path's guard.
|
|
1694
|
+
if (seed.code === 0) {
|
|
1695
|
+
const qr = await seedSearchQueriesForProject(result.project, args.search_queries);
|
|
1696
|
+
seedNote += qr.note;
|
|
1697
|
+
searchQueries = qr.queries;
|
|
1669
1698
|
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1699
|
+
}
|
|
1700
|
+
// Install/refresh the launchd kicker NOW, the moment a product project is
|
|
1701
|
+
// ready — identical to the persona path (engagement_mode). Before this, a
|
|
1702
|
+
// promotion-only setup never installed the kicker at setup time (the persona
|
|
1703
|
+
// path explicitly skips promotion-only, and project_config didn't pick it
|
|
1704
|
+
// up), so drafting didn't start until a later Claude/queue-worker boot ran
|
|
1705
|
+
// the boot-time install. ensureQueueKickerInstalled is idempotent + product/
|
|
1706
|
+
// persona-aware, so calling it from both setup paths is safe. Best-effort:
|
|
1707
|
+
// a kicker hiccup never fails setup. (2026-06-30)
|
|
1708
|
+
let kickerInstall = null;
|
|
1709
|
+
if (result.ready) {
|
|
1710
|
+
try {
|
|
1711
|
+
kickerInstall = await ensureQueueKickerInstalled();
|
|
1712
|
+
console.error(`[project_config] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
|
|
1713
|
+
}
|
|
1714
|
+
catch (e) {
|
|
1715
|
+
kickerInstall = { ok: false, detail: e?.message || String(e) };
|
|
1716
|
+
console.error("[project_config] kicker install failed:", e?.message || e);
|
|
1672
1717
|
}
|
|
1673
1718
|
}
|
|
1674
1719
|
// Surface any advanced (escape-hatch) field edits in the note so the
|
|
@@ -1691,6 +1736,8 @@ tool("project_config", {
|
|
|
1691
1736
|
topics_seeded: topicsSeeded,
|
|
1692
1737
|
topic_count: topicCount,
|
|
1693
1738
|
search_queries: searchQueries,
|
|
1739
|
+
kicker_installed: kickerInstall ? kickerInstall.ok : null,
|
|
1740
|
+
kicker_detail: kickerInstall ? kickerInstall.detail : null,
|
|
1694
1741
|
fields_set: result.fields_set,
|
|
1695
1742
|
fields_removed: result.fields_removed,
|
|
1696
1743
|
config_path: configPath(),
|
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.6.
|
|
5
|
+
"version": "1.6.180",
|
|
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": {
|
|
@@ -1476,6 +1476,16 @@ class S4LMenuBar(rumps.App):
|
|
|
1476
1476
|
# and would freeze the card UI while the user reviews the rest of the stack.
|
|
1477
1477
|
if not decision.get("approved"):
|
|
1478
1478
|
n = decision.get("n")
|
|
1479
|
+
# Durable local record FIRST, mirroring approved_queue_add for approvals.
|
|
1480
|
+
# review_drafts() consults this, so the rejected card is suppressed from
|
|
1481
|
+
# re-review IMMEDIATELY and even if the loopback is down when the
|
|
1482
|
+
# background plan-flag write below runs. Without this, a reject was a
|
|
1483
|
+
# fire-and-forget loopback call with a swallowed exception, so rejects
|
|
1484
|
+
# silently vanished and the card "came back" — unlike durable approvals.
|
|
1485
|
+
try:
|
|
1486
|
+
st.review_reject_add(batch, n)
|
|
1487
|
+
except Exception:
|
|
1488
|
+
pass
|
|
1479
1489
|
|
|
1480
1490
|
def _persist_reject():
|
|
1481
1491
|
try:
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -488,17 +488,20 @@ def review_drafts(plan, batch="review-queue"):
|
|
|
488
488
|
posted, terminal (rejected/dead), or already approved is a settled decision and
|
|
489
489
|
must never be re-presented for review (approved ones proceed to post).
|
|
490
490
|
|
|
491
|
-
Also excludes cards
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
491
|
+
Also excludes cards with ANY durable decision (approved, edited, rejected, or a
|
|
492
|
+
decided-but-failed post) via review_settled_ns(). approve/reject/edit are now
|
|
493
|
+
IDENTICAL: each writes a durable local record the INSTANT the user clicks, so a
|
|
494
|
+
decided card never re-presents even if the loopback (Claude Desktop) is down
|
|
495
|
+
when the decision's plan-flag write is attempted. The main plan's
|
|
496
|
+
`approved`/`terminal`/`posted` flags are only stamped once the loopback write
|
|
497
|
+
lands, so without this a card the user just decided would re-present (the exact
|
|
498
|
+
"I already decided these" bug)."""
|
|
499
|
+
settled_ns = review_settled_ns(batch)
|
|
497
500
|
out = []
|
|
498
501
|
for i, c in enumerate(((plan or {}).get("candidates") or [])):
|
|
499
502
|
if c.get("posted") is True or c.get("terminal") is True or c.get("approved") is True:
|
|
500
503
|
continue
|
|
501
|
-
if (i + 1) in
|
|
504
|
+
if (i + 1) in settled_ns:
|
|
502
505
|
continue
|
|
503
506
|
out.append(
|
|
504
507
|
{
|
|
@@ -635,6 +638,28 @@ def toggle_mode():
|
|
|
635
638
|
return write_mode(new)
|
|
636
639
|
|
|
637
640
|
|
|
641
|
+
def review_reject_add(batch, n):
|
|
642
|
+
"""Record a REJECT the INSTANT the user clicks, mirroring approved_queue_add.
|
|
643
|
+
Reject and approve are now IDENTICAL: both write a durable local record before
|
|
644
|
+
any loopback call, so review_drafts() suppresses the card even if the loopback
|
|
645
|
+
(Claude Desktop) is down when the reject's plan `terminal` write is attempted.
|
|
646
|
+
Dedups on (batch, n); a reject is FINAL and overrides any earlier status."""
|
|
647
|
+
with _approved_lock:
|
|
648
|
+
d = read_approved_queue()
|
|
649
|
+
for it in d["items"]:
|
|
650
|
+
if it.get("batch") == batch and it.get("n") == n:
|
|
651
|
+
if it.get("status") != "rejected":
|
|
652
|
+
it.update(status="rejected", error=None, ts=time_iso())
|
|
653
|
+
_write_approved_queue(d)
|
|
654
|
+
return
|
|
655
|
+
d["items"].append({
|
|
656
|
+
"batch": batch, "n": n, "text": "", "edited": False,
|
|
657
|
+
"drop_link": False, "candidate_url": "", "status": "rejected",
|
|
658
|
+
"error": None, "ts": time_iso(),
|
|
659
|
+
})
|
|
660
|
+
_write_approved_queue(d)
|
|
661
|
+
|
|
662
|
+
|
|
638
663
|
def approved_queue_add(batch, n, text="", edited=False, candidate_url="", drop_link=False):
|
|
639
664
|
"""Record an approval the INSTANT the user clicks, before any posting. Dedups
|
|
640
665
|
on (batch, n): re-approving a card that's still queued/posting/posted is a
|
|
@@ -690,6 +715,18 @@ def approved_queue_active_ns(batch):
|
|
|
690
715
|
if it.get("batch") == batch and it.get("status") in ("queued", "posting", "posted")}
|
|
691
716
|
|
|
692
717
|
|
|
718
|
+
def review_settled_ns(batch):
|
|
719
|
+
"""Plan indices with ANY durable user DECISION for this batch — review_drafts()
|
|
720
|
+
excludes these so approve, edit, and reject behave IDENTICALLY: a decided card
|
|
721
|
+
never re-presents for review. Covers queued/posting/posted (approved, in flight
|
|
722
|
+
or landed), `rejected`, AND `failed` (a decided-but-failed post is surfaced via
|
|
723
|
+
the failure notification + dashboard, NOT by re-showing it as a fresh review
|
|
724
|
+
card — that re-show was the "I already decided these came back" bug)."""
|
|
725
|
+
return {it.get("n") for it in read_approved_queue()["items"]
|
|
726
|
+
if it.get("batch") == batch
|
|
727
|
+
and it.get("status") in ("queued", "posting", "posted", "failed", "rejected")}
|
|
728
|
+
|
|
729
|
+
|
|
693
730
|
def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
|
|
694
731
|
"""Post / reject drafts via the loopback tool. `post` = 1-based numbers to post
|
|
695
732
|
as-is; `edits` = [{n, text}] to rewrite then post; `reject` = numbers to mark
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/social-autoposter-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.180",
|
|
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
|
@@ -94,8 +94,13 @@ GROUNDING_INSTRUCTIONS = (
|
|
|
94
94
|
"posts/replies in the x source). Keep the organic rules: first person, "
|
|
95
95
|
"specific, no links, no feature lists, no sales, no em dashes.\n"
|
|
96
96
|
" search_topics ~15 topics they have genuine experience with.\n"
|
|
97
|
+
" content_corpus (OPTIONAL but STRONGLY encouraged) the RAW gathered "
|
|
98
|
+
"corpus as one plain-text block: the persona's actual posts, replies, repo "
|
|
99
|
+
"descriptions, site copy, verbatim. This is NOT synthesized. It becomes the "
|
|
100
|
+
"grounding pool the drafter quotes real specifics from, so keep it dense and "
|
|
101
|
+
"first-hand. Trim only obvious noise; do NOT paraphrase. Cap ~8000 chars.\n"
|
|
97
102
|
+ PUBLIC_ONLY_NOTE
|
|
98
|
-
+ "\nThen write the
|
|
103
|
+
+ "\nThen write the fields to /tmp/persona.json and run "
|
|
99
104
|
"`build_persona.py apply --from /tmp/persona.json`, or hand them to the "
|
|
100
105
|
"project_config tool for the persona project."
|
|
101
106
|
)
|
|
@@ -331,15 +336,34 @@ def cmd_apply(args) -> int:
|
|
|
331
336
|
proj["search_topics"] = [str(t).strip() for t in topics if str(t).strip()]
|
|
332
337
|
changed.append("search_topics")
|
|
333
338
|
|
|
339
|
+
# Raw corpus -> sidecar file (NOT config.json). config.json is inlined into
|
|
340
|
+
# many prompts (ALL_PROJECTS_JSON), so a multi-KB corpus there would bloat
|
|
341
|
+
# every cycle's token bill. Instead persist it beside config.json and let the
|
|
342
|
+
# persona lane read it only when it actually drafts. cmd_apply is the single
|
|
343
|
+
# writer; the cycle is read-only.
|
|
344
|
+
corpus_written = None
|
|
345
|
+
corpus = data.get("content_corpus")
|
|
346
|
+
if isinstance(corpus, str) and corpus.strip():
|
|
347
|
+
corpus_path = cfg_path.parent / "persona_corpus.txt"
|
|
348
|
+
try:
|
|
349
|
+
corpus_path.write_text(corpus.strip()[:8000] + "\n")
|
|
350
|
+
corpus_written = str(corpus_path)
|
|
351
|
+
changed.append("content_corpus")
|
|
352
|
+
except Exception as e:
|
|
353
|
+
print(f"[build_persona] corpus sidecar write failed: {e}", file=sys.stderr)
|
|
354
|
+
|
|
334
355
|
if args.dry_run:
|
|
335
356
|
print(json.dumps({"would_update": name, "fields": changed,
|
|
336
|
-
"search_topics": proj.get("search_topics")
|
|
357
|
+
"search_topics": proj.get("search_topics"),
|
|
358
|
+
"corpus_sidecar": corpus_written}, indent=2))
|
|
337
359
|
return 0
|
|
338
360
|
|
|
339
361
|
tmp = cfg_path.with_suffix(".json.tmp")
|
|
340
362
|
tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n")
|
|
341
363
|
tmp.replace(cfg_path)
|
|
342
364
|
print(f"[build_persona] updated config.json persona {name!r}: {', '.join(changed)}")
|
|
365
|
+
if corpus_written:
|
|
366
|
+
print(f"[build_persona] wrote raw corpus sidecar -> {corpus_written}")
|
|
343
367
|
|
|
344
368
|
# Seed the (possibly new) topics into project_search_topics via the canonical
|
|
345
369
|
# path, so pick_search_topic has a live universe for the persona.
|
|
@@ -828,7 +828,12 @@ def _maybe_leak_alert(output: Path, current: dict[str, Any]) -> None:
|
|
|
828
828
|
|
|
829
829
|
Runs on the JSONL path (every ~minute), reading its own recent history from the
|
|
830
830
|
file just written, so it needs no extra state beyond a small cooldown marker."""
|
|
831
|
-
|
|
831
|
+
# Watch claude_cli (the runaway worker fan-out) and the claude sessions
|
|
832
|
+
# CONFIGURED with the remote-macos-use MCP. Karol's 06-30 double-leak lived
|
|
833
|
+
# entirely in these two: claude_cli 289 + sessions_configured_remote_macos_mcp
|
|
834
|
+
# 280 at peak, while remote_macos_mcp_servers (the standalone server procs)
|
|
835
|
+
# stayed 0 the whole time. Watching the server group would have been blind.
|
|
836
|
+
groups_to_watch = ("claude_cli", "sessions_configured_remote_macos_mcp")
|
|
832
837
|
samples = _env_int("SAPS_LEAK_ALERT_SAMPLES", 5) # consecutive climbs required
|
|
833
838
|
floor = _env_int("SAPS_LEAK_ALERT_FLOOR", 20) # ignore below this count
|
|
834
839
|
climb_min = _env_int("SAPS_LEAK_ALERT_CLIMB_MIN", 12) # min first->last growth
|
package/scripts/saps_mode.py
CHANGED
|
@@ -217,6 +217,11 @@ def _persona_env_lines() -> str:
|
|
|
217
217
|
[
|
|
218
218
|
f"export SAPS_FORCE_PROJECT={shlex.quote(name)}",
|
|
219
219
|
"export TWITTER_TAIL_LINK_RATE=0",
|
|
220
|
+
# Explicit lane signal so the (locked) cycle can branch the draft
|
|
221
|
+
# directive + inject the persona corpus without re-deriving the lane
|
|
222
|
+
# from SAPS_FORCE_PROJECT (which is also set by manual single-project
|
|
223
|
+
# MCP draft_cycle runs). Only the personal_brand lane sets this.
|
|
224
|
+
"export SAPS_ACTIVE_LANE=personal_brand",
|
|
220
225
|
]
|
|
221
226
|
)
|
|
222
227
|
|
|
@@ -171,6 +171,14 @@ def resolve_link(candidate: dict, projects: dict, page_gen_rate: float) -> tuple
|
|
|
171
171
|
"""
|
|
172
172
|
proj_name = candidate.get("matched_project") or ""
|
|
173
173
|
proj = projects.get(proj_name) or {}
|
|
174
|
+
# Personal-brand (persona) lane is link-free by definition. Self-promotion
|
|
175
|
+
# mode is pure organic engagement: no company, no signup, no profile URL. Any
|
|
176
|
+
# `website`/`url` a persona project happens to carry (some installs got the
|
|
177
|
+
# user's own X profile written there) must NEVER become a tail link. Enforce
|
|
178
|
+
# it here at the single source so no downstream surface (review card, manual
|
|
179
|
+
# post_drafts) has to strip a link that should never have been generated.
|
|
180
|
+
if proj.get("persona"):
|
|
181
|
+
return ("", "persona_no_link")
|
|
174
182
|
plain_url = proj.get("website") or proj.get("url") or ""
|
|
175
183
|
has_lp = bool(candidate.get("has_landing_pages"))
|
|
176
184
|
keyword = (candidate.get("link_keyword") or "").strip()
|
|
@@ -1730,7 +1730,28 @@ done <<< "$CANDIDATES"
|
|
|
1730
1730
|
ALL_PROJECTS_JSON=$(python3 -c "
|
|
1731
1731
|
import json, os
|
|
1732
1732
|
config = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
|
|
1733
|
-
|
|
1733
|
+
projects = config.get('projects', [])
|
|
1734
|
+
lane = os.environ.get('SAPS_ACTIVE_LANE', '')
|
|
1735
|
+
if lane == 'personal_brand':
|
|
1736
|
+
# Personal-brand lane is pure organic growth: the drafter must NOT see any
|
|
1737
|
+
# product config at all (no website, links, booking_link, get_started_link,
|
|
1738
|
+
# features, pricing, CTAs). We emit ONLY the persona project, and ONLY the
|
|
1739
|
+
# drafting-relevant fields, so there is literally no product context in the
|
|
1740
|
+
# prompt to accidentally pitch, quote, or link. This also kills cross-routing
|
|
1741
|
+
# (no 'other project' exists to route a candidate to). Whitelist, not
|
|
1742
|
+
# denylist: any field added to the persona entry later stays out unless
|
|
1743
|
+
# explicitly allowed here.
|
|
1744
|
+
ALLOWED = {
|
|
1745
|
+
'name', 'description', 'content_angle', 'voice',
|
|
1746
|
+
'voice_relationship', 'content_guardrails',
|
|
1747
|
+
}
|
|
1748
|
+
persona = next((p for p in projects if p.get('persona') is True), None)
|
|
1749
|
+
out = {}
|
|
1750
|
+
if persona:
|
|
1751
|
+
out[persona['name']] = {k: v for k, v in persona.items() if k in ALLOWED}
|
|
1752
|
+
print(json.dumps(out, indent=2))
|
|
1753
|
+
else:
|
|
1754
|
+
print(json.dumps({p['name']: p for p in projects}, indent=2))
|
|
1734
1755
|
" 2>/dev/null || echo "{}")
|
|
1735
1756
|
|
|
1736
1757
|
# Engagement-style picker (2026-05-19): pick ONE assigned style for this
|
|
@@ -1796,6 +1817,14 @@ if [ "$SAPS_DRAFT_PROMPT_VARIANT" = "treatment" ]; then
|
|
|
1796
1817
|
else
|
|
1797
1818
|
DRAFT_DIRECTIVE="Otherwise: draft a reply using the best engagement style. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present."
|
|
1798
1819
|
fi
|
|
1820
|
+
# Personal-brand lane (SAPS_ACTIVE_LANE=personal_brand, set by saps_mode.py):
|
|
1821
|
+
# replace the product-framed directive entirely. This lane is pure organic
|
|
1822
|
+
# growth: no product, no link, no CTA. The reply must add real value grounded in
|
|
1823
|
+
# the persona's first-hand material (the PERSONA CORPUS block + the persona voice
|
|
1824
|
+
# block), not concede-and-agree filler. Overrides both A/B arms above.
|
|
1825
|
+
if [ "${SAPS_ACTIVE_LANE:-}" = "personal_brand" ]; then
|
|
1826
|
+
DRAFT_DIRECTIVE="Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the PERSONA CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's \`voice\` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is'). This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present."
|
|
1827
|
+
fi
|
|
1799
1828
|
|
|
1800
1829
|
if [ -n "$PICKED_STYLE" ]; then
|
|
1801
1830
|
TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --style "$PICKED_STYLE" 2>/dev/null || echo "(top performers report unavailable)")
|
|
@@ -1903,6 +1932,21 @@ else
|
|
|
1903
1932
|
fi
|
|
1904
1933
|
rm -f "$MEDIA_URLS_FILE" 2>/dev/null || true
|
|
1905
1934
|
|
|
1935
|
+
# --- PERSONA CORPUS injection (personal_brand lane only) --------------------
|
|
1936
|
+
# build_persona.py apply writes a raw first-hand corpus sidecar next to
|
|
1937
|
+
# config.json. In the personal_brand lane we inline it so the drafter grounds
|
|
1938
|
+
# replies in real specifics (projects, numbers, opinions) instead of the single
|
|
1939
|
+
# synthesized content_angle paragraph. Empty string in the promotion lane, so
|
|
1940
|
+
# promotion prompts stay lean and config.json is never bloated with the corpus.
|
|
1941
|
+
CORPUS_BLOCK=""
|
|
1942
|
+
if [ "${SAPS_ACTIVE_LANE:-}" = "personal_brand" ] && [ -f "$REPO_DIR/persona_corpus.txt" ]; then
|
|
1943
|
+
CORPUS_BLOCK="## PERSONA CORPUS (raw first-hand material — ground your reply in THIS)
|
|
1944
|
+
This is the persona's own public writing and work, verbatim. Quote and draw real specifics from it: actual projects, real numbers, sharp opinions, real failures. Do NOT invent anything not supported here or in the persona voice block. Use it to make the reply concrete and unmistakably human.
|
|
1945
|
+
$(cat "$REPO_DIR/persona_corpus.txt")
|
|
1946
|
+
"
|
|
1947
|
+
log "Phase 2b-prep: injected persona corpus ($(wc -c < "$REPO_DIR/persona_corpus.txt" | tr -d ' ') bytes)."
|
|
1948
|
+
fi
|
|
1949
|
+
|
|
1906
1950
|
log "Phase 2b-prep: Claude reading threads and drafting replies (no post cap)..."
|
|
1907
1951
|
|
|
1908
1952
|
# Pre-assign the prep session UUID in the parent shell so it survives the
|
|
@@ -1926,7 +1970,7 @@ PREP_SCHEMA='{"type":"object","properties":{"candidates":{"type":"array","items"
|
|
|
1926
1970
|
PREP_PROMPT="${TW_ENGINE_PREFIX}You are the Social Autoposter prep step.
|
|
1927
1971
|
|
|
1928
1972
|
Your ONLY job in THIS session:
|
|
1929
|
-
1. Read each candidate's thread context from the PRE-SCORED CANDIDATES block below (each entry's 'Text:' field is the parent tweet). You have
|
|
1973
|
+
1. Read each candidate's thread context from the PRE-SCORED CANDIDATES block below (each entry's 'Text:' field is the parent tweet). You have WebSearch and WebFetch available: use them ONLY when a thread hinges on a current fact, a name, a release, or a claim you are not sure about, so your reply is specific and correct instead of vague. You do NOT have the Twitter/X browser this session — never fetch, navigate, or open a tweet/x.com URL, and never try to load the thread itself; the thread text you need is already inlined below. Most replies need no search at all; reach for it only when it materially improves the reply.
|
|
1930
1974
|
2. Draft a reply for each.
|
|
1931
1975
|
3. Persist each fresh draft via log_draft.py.
|
|
1932
1976
|
4. Emit a structured plan describing the chosen candidates, the reply text, and (when applicable) the SEO link keyword + slug.
|
|
@@ -1940,6 +1984,7 @@ Read $REPO_DIR/config.json for project metadata.
|
|
|
1940
1984
|
Virality is a composite predictor of how big this thread will get AFTER you reply: it combines engagement velocity (eng/hour), author reach (follower tier), age decay (6h half-life), retweet ratio, reply count, and discussion quality (reply:like ratio). On historical posted data the highest-Virality cohort (score >= 10000) received ~36x the median reply views of the lowest cohort (score < 10), so prioritize on-brand candidates with HIGH Virality. Rule of thumb: Virality >= 100 = strong thread on a real growth curve, your reply is likely to land 10-100x more eyeballs than a low-Virality thread. Delta (5min) is the raw T1-T0 engagement count and is shown for context only; do not re-rank on Delta.
|
|
1941
1985
|
$CANDIDATE_BLOCK
|
|
1942
1986
|
$MEDIA_BLOCK
|
|
1987
|
+
$CORPUS_BLOCK
|
|
1943
1988
|
|
|
1944
1989
|
## PROJECT ROUTING (per-candidate)
|
|
1945
1990
|
Each candidate has a 'Project match' field. Use that project unless the thread content clearly better fits another project.
|
|
@@ -1955,7 +2000,7 @@ There is NO cap on how many candidates you may pick this cycle. Pick EVERY candi
|
|
|
1955
2000
|
|
|
1956
2001
|
For each chosen candidate:
|
|
1957
2002
|
1. Read the candidate's parent tweet from its 'Text:' field in the PRE-SCORED CANDIDATES block above.
|
|
1958
|
-
2. Understand the context from that inlined text (you have
|
|
2003
|
+
2. Understand the context from that inlined text (the thread text is already in this prompt; you do NOT have the Twitter browser, but you MAY use WebSearch/WebFetch for external facts when a thread needs them to be answered well).
|
|
1959
2004
|
3. DRAFT HANDLING (existing vs fresh):
|
|
1960
2005
|
- If the candidate block shows an EXISTING DRAFT line AND draft age < 30 minutes, REUSE the draft text verbatim. Set engagement_style to the existing style. Do NOT call log_draft.py; do NOT redraft. Reason: prior cycle paid the LLM cost.
|
|
1961
2006
|
- $DRAFT_DIRECTIVE
|
|
@@ -2017,7 +2062,7 @@ CRITICAL:
|
|
|
2017
2062
|
- DO NOT call twitter_browser.py.
|
|
2018
2063
|
- DO NOT call generate_page.py (the shell runs it AFTER your session, outside the lock).
|
|
2019
2064
|
- DO NOT call log_post.py or campaign_bump.py.
|
|
2020
|
-
- You
|
|
2065
|
+
- You do NOT have the Twitter/X browser this session: never navigate, fetch, or open a tweet/x.com URL, and never try to reload the thread. WebSearch/WebFetch ARE available for external fact-checking only; use them sparingly and never to open the tweet itself.
|
|
2021
2066
|
- NEVER use em dashes. Use commas, periods, or regular dashes (-).
|
|
2022
2067
|
- Reply in the SAME LANGUAGE as the parent tweet."
|
|
2023
2068
|
|
|
@@ -2025,7 +2070,15 @@ CRITICAL:
|
|
|
2025
2070
|
# On Linux ARG_MAX is 2MB; the assembled prompt (config.json + top_report +
|
|
2026
2071
|
# styles + schema + candidates) busts that on the VM, dying with E2BIG
|
|
2027
2072
|
# "Argument list too long". stdin has no such cap.
|
|
2028
|
-
|
|
2073
|
+
# --allowedTools: restore external fact-checking to the prep drafter (removed
|
|
2074
|
+
# 2026-06-26). --strict-mcp-config stays so the twitter-harness browser MCP is NOT
|
|
2075
|
+
# loaded: the model can search the web but can never touch the CDP Chrome that
|
|
2076
|
+
# Phase 2b-post drives (that would break the two-lock). The tools are passed as a
|
|
2077
|
+
# SINGLE comma-separated token on purpose: claude_job.py's queue parser (box
|
|
2078
|
+
# installs) treats --allowedTools as a one-value flag, so a space-separated second
|
|
2079
|
+
# tool would leak in as the prompt. On the box these flags ride through
|
|
2080
|
+
# claude_job.py; Desktop's own web search + the reworded prompt enable it there.
|
|
2081
|
+
PREP_OUTPUT=$(printf '%s' "$PREP_PROMPT" | "$REPO_DIR/scripts/run_claude.sh" "run-twitter-cycle-prep" --strict-mcp-config --mcp-config "$TW_MCP_CONFIG" --allowedTools WebSearch,WebFetch -p --output-format json --json-schema "$PREP_SCHEMA" 2>&1)
|
|
2029
2082
|
|
|
2030
2083
|
echo "$PREP_OUTPUT" >> "$LOG_FILE"
|
|
2031
2084
|
|
|
@@ -2035,14 +2088,12 @@ echo "$PREP_OUTPUT" >> "$LOG_FILE"
|
|
|
2035
2088
|
# volume (the May-June ~10x ramp that collapsed per-post reach ~15x) while
|
|
2036
2089
|
# keeping the strongest thread. Deferred picks are dropped from the plan so they
|
|
2037
2090
|
# stay status='pending' (NOT 'skipped'); Phase 0 salvage re-judges them next
|
|
2038
|
-
# cycle and reuses their fresh drafts.
|
|
2039
|
-
#
|
|
2040
|
-
#
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
POST_TOP_N="${SAPS_TWITTER_POST_TOP_N:-1}"
|
|
2045
|
-
fi
|
|
2091
|
+
# cycle and reuses their fresh drafts. (2026-06-30) The cap is now the SINGLE
|
|
2092
|
+
# standard for BOTH lanes: autopilot direct-post AND DRAFT_ONLY manual MCP review.
|
|
2093
|
+
# The old DRAFT_ONLY=1 -> POST_TOP_N=0 special-case was removed on purpose, so the
|
|
2094
|
+
# human reviews the exact same one highest-Virality draft the autopilot would post.
|
|
2095
|
+
# Override with SAPS_TWITTER_POST_TOP_N (default 1; 0 = no cap, env opt-out only).
|
|
2096
|
+
POST_TOP_N="${SAPS_TWITTER_POST_TOP_N:-1}"
|
|
2046
2097
|
|
|
2047
2098
|
# Parse the prep envelope and write the plan to \$PLAN_FILE; also extract the
|
|
2048
2099
|
# 'rejected' array into \$SKIP_FILE so log_twitter_skips.py can persist a
|
|
@@ -2065,7 +2116,8 @@ rejected = so.get('rejected', []) if isinstance(so, dict) else []
|
|
|
2065
2116
|
# TOP-N POST CAP (2026-06-29): keep only the highest-Virality on-brand pick(s).
|
|
2066
2117
|
# Build candidate_id -> virality_score from the pre-scored CANDIDATES block
|
|
2067
2118
|
# (pipe cols: id|url|author|text|virality|delta|...), sort the model's picks by
|
|
2068
|
-
# it, and truncate. SAPS_POST_TOP_N=0 disables the cap (
|
|
2119
|
+
# it, and truncate. SAPS_POST_TOP_N=0 disables the cap (env opt-out only; the cap
|
|
2120
|
+
# applies to both autopilot and DRAFT_ONLY lanes as of 2026-06-30).
|
|
2069
2121
|
# Truncated picks are simply dropped from the plan, so they stay status='pending'
|
|
2070
2122
|
# (NOT added to 'rejected'); no thread is blacklisted and Phase 0 salvages them.
|
|
2071
2123
|
_top_n = int(os.environ.get('SAPS_POST_TOP_N', '1') or '1')
|