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 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
- // ---- project_config: per-project config (the "brain": project, website, voice) -----
1034
- // Run this FIRST. The action tools refuse until at least one project is ready.
1035
- // You can set up MULTIPLE products and fill each project's fields INCREMENTALLY
1036
- // across several calls — readiness is derived from config.json, never a stored
1037
- // flag. Call with status:true (or just no name) to list every project this
1038
- // install manages and what each still needs.
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. If they want promotion too, continue to " +
1055
- "configure the product project with project_config afterward. The user flips either lane any " +
1056
- "time from the menu-bar checkmarks.",
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
- : "Personal-brand lane is on (the default); the persona is provisioned + topic-seeded, so there " +
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: fan the seeded topics out into >=30 real X
1620
- // search queries (project_search_queries) so the deterministic Phase 1
1621
- // bank (qualified_query_bank.py) has something to run on day one.
1622
- // Without this, a freshly-configured project's bank is empty and the
1623
- // cycle falls back to ONE crude topic-as-query.
1624
- //
1625
- // CLAUDE-FREE: the in-session agent (you) expands topics -> queries and
1626
- // passes them as `search_queries` in THIS call. We seed them directly via
1627
- // --queries-json, so setup never shells out to `claude -p` (which isn't
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
- else if (seed.code === 0) {
1671
- seedNote += ` (No search_queries supplied, so the cycle will run off the seeded topics one at a time. To fan out, re-call project_config with a search_queries array of ~30 X search strings you expand from these topics — it seeds them directly, no claude CLI.)`;
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(),
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.178",
3
- "installedAt": "2026-07-01T02:07:11.872Z"
2
+ "version": "1.6.180",
3
+ "installedAt": "2026-07-01T17:24:56.073Z"
4
4
  }
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.178",
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:
@@ -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 already sitting in the durable approved-queue (queued or
492
- posting). The main plan's `approved`/`posted` flags are only stamped once a
493
- post LANDS, so without this a card the user just approved would re-present
494
- while it drains and after a restart the whole un-posted approval backlog
495
- would re-present (the exact "I approved these already" bug)."""
496
- approved_ns = approved_queue_active_ns(batch)
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 approved_ns:
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.178",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.178",
3
+ "version": "1.6.180",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -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 four fields to /tmp/persona.json and run "
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")}, indent=2))
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
- groups_to_watch = ("claude_cli", "remote_macos_mcp_servers")
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
@@ -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
- print(json.dumps({p['name']: p for p in config.get('projects', [])}, indent=2))
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 NO browser and NO tools this session draft ONLY from the context inlined in this prompt; do not attempt to fetch, navigate, or open any URL.
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 no browser; everything you need to draft is already in this prompt).
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 have NO browser and NO tools this session; draft only from the inlined candidate context above. Do not navigate, fetch, or open any URL.
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
- PREP_OUTPUT=$(printf '%s' "$PREP_PROMPT" | "$REPO_DIR/scripts/run_claude.sh" "run-twitter-cycle-prep" --strict-mcp-config --mcp-config "$TW_MCP_CONFIG" -p --output-format json --json-schema "$PREP_SCHEMA" 2>&1)
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. DRAFT_ONLY (manual MCP review) keeps every
2039
- # draft so the human sees the full set. Override with SAPS_TWITTER_POST_TOP_N
2040
- # (default 1; 0 = no cap).
2041
- if [ "${DRAFT_ONLY:-0}" = "1" ]; then
2042
- POST_TOP_N=0
2043
- else
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 (manual DRAFT_ONLY review).
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')