social-autoposter 1.6.50 → 1.6.52

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
@@ -309,10 +309,24 @@ function renderDraftsTable(plan) {
309
309
  const author = c.thread_author ? `@${c.thread_author}` : "(unknown thread)";
310
310
  const style = c.engagement_style ?? "?";
311
311
  const reply = c.reply_text ?? "(empty)";
312
- const link = c.link_url ? ` · link: ${c.link_url}` : "";
313
- return (`[${n}] ${author} (style: ${style})${link}\n` +
314
- ` ${reply.replace(/\n/g, "\n ")}\n` +
315
- ` thread: ${c.candidate_url ?? "?"}`);
312
+ // The literal tail URL is NOT known yet: at post time a short link is minted
313
+ // from this target (e.g. fazm.ai/cc -> s4l.ai/r/<code>). Approved drafts
314
+ // always carry the link (post_drafts forces TWITTER_TAIL_LINK_RATE=1.0), so
315
+ // this is the target that WILL be appended. Show the TARGET only; never
316
+ // pre-mint the real /r/ code (that would waste pool codes / split clicks).
317
+ const link = c.link_url
318
+ ? `\n + link (appended as a short link at post time): ${c.link_url}`
319
+ : "";
320
+ // The original tweet we're replying to — context the reviewer needs to judge
321
+ // the draft. Already in the plan; just surface it.
322
+ const threadText = c.thread_text
323
+ ? `\n in reply to: ${c.thread_text.replace(/\s+/g, " ").trim().slice(0, 280)}`
324
+ : "";
325
+ return (`[${n}] ${author} (style: ${style})` +
326
+ `${threadText}\n` +
327
+ ` draft: ${reply.replace(/\n/g, "\n ")}` +
328
+ `${link}\n` +
329
+ ` thread url: ${c.candidate_url ?? "?"}`);
316
330
  })
317
331
  .join("\n\n");
318
332
  }
@@ -331,6 +345,16 @@ async function postApproved(batchId, plan) {
331
345
  timeoutMs: 900_000,
332
346
  env: {
333
347
  SAPS_SKIP_CAMPAIGN_SUFFIX: "1",
348
+ // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
349
+ // runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
350
+ // ship link-less as an experiment arm. But when the user hand-reviews a
351
+ // draft, sees the link target in the table, and approves it, dropping the
352
+ // link is surprising and unwanted. Force 1.0 here so every approved draft
353
+ // carries its link. This wins over .env / process.env because run() spreads
354
+ // opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
355
+ // with override, so nothing clobbers it. Cron is untouched (it never goes
356
+ // through this MCP path), so the 0.9 experiment keeps running there.
357
+ TWITTER_TAIL_LINK_RATE: "1.0",
334
358
  // The poster attaches to the twitter-harness Chrome over CDP. The cron
335
359
  // pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
336
360
  // must set it explicitly or twitter_browser.py fails with "No twitter-
@@ -582,7 +606,9 @@ server.registerTool("draft_cycle", {
582
606
  description: "Scan X and draft replies on this machine, then return ALL drafts as a numbered table " +
583
607
  "for review. This tool POSTS NOTHING. Show the table to the user and ask which numbers " +
584
608
  "to post and which to rewrite, then call `post_drafts` with their decision and the " +
585
- "returned batch_id. Flow: discover -> draft -> review in chat -> post_drafts.",
609
+ "returned batch_id. The table MUST show, per draft: the thread being replied to " +
610
+ "(thread_text), the draft reply, and the link target (link_url) when present; never " +
611
+ "drop those columns. Flow: discover -> draft -> review in chat -> post_drafts.",
586
612
  inputSchema: {
587
613
  project: z
588
614
  .string()
@@ -639,11 +665,15 @@ server.registerTool("draft_cycle", {
639
665
  const message = `Drafted ${count} ${count === 1 ? "reply" : "replies"} for "${proj}" ` +
640
666
  `(batch ${drafted.batchId}). NOTHING has been posted yet.\n\n` +
641
667
  `${table}\n\n` +
642
- `Show this list to the user and ask which to post and which to edit. They can reply ` +
643
- `however is natural, e.g. "post 1, 3 and 5", "edit 2: <new wording>", "post all", or ` +
644
- `"skip all". Editing a draft also posts it. Then call the post_drafts tool with ` +
645
- `batch_id "${drafted.batchId}" and their decision (post: [numbers], edits: [{n, text}], ` +
646
- `or post_all: true). Do not post anything the user didn't ask for.`;
668
+ `Show this list to the user and ask which to post and which to edit. When you render ` +
669
+ `the table, ALWAYS include, for every draft: the thread it replies to (in reply to / ` +
670
+ `thread_text), the draft text, and the link target (link_url) if present. Do NOT drop ` +
671
+ `the thread or link columns. Note the literal short link is appended at post time and ` +
672
+ `an A/B gate may omit it, so present link_url as the target, not a guaranteed final URL. ` +
673
+ `They can reply however is natural, e.g. "post 1, 3 and 5", "edit 2: <new wording>", ` +
674
+ `"post all", or "skip all". Editing a draft also posts it. Then call the post_drafts ` +
675
+ `tool with batch_id "${drafted.batchId}" and their decision (post: [numbers], ` +
676
+ `edits: [{n, text}], or post_all: true). Do not post anything the user didn't ask for.`;
647
677
  return {
648
678
  content: [{ type: "text", text: message }],
649
679
  structuredContent: {
@@ -659,7 +689,15 @@ server.registerTool("draft_cycle", {
659
689
  n: i + 1,
660
690
  author: c.thread_author,
661
691
  tweet_url: c.candidate_url,
692
+ // The original tweet being replied to — reviewer context. Hosts that
693
+ // surface ONLY structuredContent (Claude Desktop, Fazm Code tab) need
694
+ // this here or the relayed table loses the thread it's responding to.
695
+ thread_text: c.thread_text,
662
696
  reply_text: c.reply_text,
697
+ // Target link only. The literal /r/<code> short link is minted at post
698
+ // time and an A/B gate may omit it; do not pre-mint here.
699
+ link_url: c.link_url,
700
+ style: c.engagement_style,
663
701
  language: c.language,
664
702
  })),
665
703
  },
@@ -249,11 +249,32 @@ def _build_chrome_cmd() -> list[str]:
249
249
  cmd.append(f"--window-position={win_pos}")
250
250
  cmd.append(f"--window-size={win_size}")
251
251
 
252
- # Open a real tab so CDP has something to attach to immediately.
253
- cmd.append("about:blank")
252
+ # Open a REAL http(s) page (NOT about:blank) so the harness daemon's
253
+ # attach_first_page() finds an existing real tab and reuses it. Upstream
254
+ # browser-harness creates a throwaway about:blank on every re-attach where
255
+ # is_real_page() is false (which about:blank is); launching blank feeds that
256
+ # loop, piling up orphan tabs that cleanup_harness_tabs then has to sweep.
257
+ # Landing URL is derived from the profile's platform; BH_LAUNCH_URL overrides.
258
+ cmd.append(_launch_url())
254
259
  return cmd
255
260
 
256
261
 
262
+ def _launch_url() -> str:
263
+ """Initial tab URL for the managed Chrome. MUST be a real http(s) page, not
264
+ about:blank, so the browser-harness daemon reuses it instead of spawning
265
+ throwaway blank tabs (see _build_chrome_cmd). Derived from PROFILE_NAME;
266
+ BH_LAUNCH_URL overrides for non-standard profiles."""
267
+ override = os.environ.get("BH_LAUNCH_URL", "").strip()
268
+ if override:
269
+ return override
270
+ name = PROFILE_NAME.lower()
271
+ if "linkedin" in name:
272
+ return "https://www.linkedin.com/feed/"
273
+ if "reddit" in name:
274
+ return "https://www.reddit.com/"
275
+ return "https://x.com"
276
+
277
+
257
278
  def ensure_chrome() -> dict:
258
279
  """Make sure our managed Chrome is running on PORT. Idempotent."""
259
280
  if _cdp_alive():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.50",
3
+ "version": "1.6.52",
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"
@@ -182,20 +182,24 @@ STYLES = {
182
182
  },
183
183
  "ig_walkin_storefront_playbook": {
184
184
  "description": (
185
- "Door-to-door SMB story for mk0r: 'i was [working-class role]. i "
186
- "drove past N businesses with no website every day. a friend told "
187
- "me about mk0r. i walked in / sat at the counter. i opened mk0r.com, "
188
- "typed one prompt, the site built itself while [owner] watched. "
189
- "they paid me $[300-500] cash from a [coffee tin / register]. N "
190
- "months later i've signed [N] of these = $[N]/mo recurring.' Ends "
191
- "with the mk0r.com footer."
185
+ "Product demo for mk0r: show mk0r building a REAL website for a "
186
+ "local business that has none. 'i noticed [a local spot] near me "
187
+ "with no website, just a maps pin and one blurry photo. i opened "
188
+ "mk0r.com, described the place in one prompt, and watched it build "
189
+ "a real site, hero, services, hours, a call button, live in "
190
+ "minutes.' Focus on the CAPABILITY and the speed, and what it "
191
+ "means for a small business to finally have a real site online. NO "
192
+ "earnings, NO 'they paid me', NO recurring-revenue or 'signed N "
193
+ "clients' totals. Reference mk0r.com plainly."
192
194
  ),
193
195
  "example": (
194
- "i was 27. shipping clerk at a parts warehouse outside fresno for "
195
- "4 years, $21 an hour. i drove past 14 auto shops on the way home "
196
- "every night. ... i opened mk0r.com on his waiting room table and "
197
- "turned it around. he paid me from the register drawer. ... 23 "
198
- "auto shops signed since march. 🔗 mk0r.com"
196
+ "there's a tire shop two blocks from me thats been open fifteen "
197
+ "years. i went to send a friend the link and there was no link, "
198
+ "just a maps pin and one blurry photo someone else uploaded. so i "
199
+ "opened mk0r.com and described the shop in one prompt, brakes, "
200
+ "tires, the hours, the phone number. it built the whole site while "
201
+ "i watched, a hero, a services list, a map, one big call button. "
202
+ "fifteen years open and it finally has a front door online. mk0r.com"
199
203
  ),
200
204
  "best_in": {
201
205
  "instagram": [
@@ -204,29 +208,40 @@ STYLES = {
204
208
  ],
205
209
  },
206
210
  "note": (
207
- "Caption is the niche walk-in arc (working-class persona, drive-by "
208
- "niche, first walk-in success, recurring revenue total). Overlay "
209
- "is the 5-beat playbook (title card + 4 step cards). MUST include "
210
- "'mk0r.com' in caption and the mk0r.com footer. project_name='mk0r' "
211
- "on the row. Fires when TARGET=product AND selected_project=mk0r."
211
+ "Caption is the product-demo arc: a real local business with no "
212
+ "website, mk0r builds a real site from one prompt, what it means "
213
+ "for the owner to finally be online. NO income/earnings/'they paid "
214
+ "me'/recurring-revenue/'signed N clients' framing -- that arc "
215
+ "tripped a Meta fraud-and-deceptive-practices restriction on "
216
+ "2026-06-02 (matt_diak link-sharing restricted 30d). Reference "
217
+ "'mk0r.com' plainly in the caption. project_name='mk0r' on the "
218
+ "row. Fires when TARGET=product AND selected_project=mk0r."
212
219
  ),
213
220
  "target_chars": 1800,
214
221
  },
215
222
  "ig_studyly_failing_student_arc": {
216
223
  "description": (
217
- "Failing-student outcome arc for studyly: 'i was [age], [program]. "
218
- "i [failed/scored low] on [exam]. i [reread/flashcards/highlights] "
219
- "for weeks. nothing worked. a friend sent me studyly.io. i pasted "
220
- "my [notes/chapter] in. it quizzed me until i could answer without "
221
- "looking. i got [higher score].' Closes with rereading-is-theater "
222
- "lesson + studyly.io footer."
224
+ "Study-method arc for studyly: 'i was [age], [program]. i was "
225
+ "[rereading/highlighting/making flashcards] for hours and still "
226
+ "blanking the moment the wording changed. a friend sent me "
227
+ "studyly.io. i pasted my [notes/chapter] in and it quizzed me on "
228
+ "my own material until i could answer without looking, rewording "
229
+ "each question so i couldnt pattern-match. for the first time i "
230
+ "could tell what i actually knew from what i only recognized.' "
231
+ "Focus on the METHOD shift (active recall vs rereading, testing on "
232
+ "your own deck). Closes with the rereading-is-theater lesson + "
233
+ "studyly.io footer. Do NOT promise or fabricate specific "
234
+ "before/after exam scores as a typical result."
223
235
  ),
224
236
  "example": (
225
- "i was 19. premed track, third semester, organic chemistry. i "
226
- "failed the first orgo exam. 47 out of 100. ... i pasted my notes "
227
- "into studyly.io at 2am. ... i got 78 on the second exam. ... the "
228
- "lesson is rereading is recognizing. close the book. let something "
229
- "ask you. studyly.io"
237
+ "i was 19. premed, third semester, organic chemistry. i had reread "
238
+ "the chapter four times and could still blank on a mechanism the "
239
+ "second the wording changed. i pasted my notes into studyly.io at "
240
+ "2am and it asked me the things i thought i knew, rewording each "
241
+ "one so i couldnt coast on the first three words. that was the "
242
+ "first night i could actually tell what i knew from what i only "
243
+ "recognized. the lesson is rereading is recognizing. close the "
244
+ "book. let something ask you. studyly.io"
230
245
  ),
231
246
  "best_in": {
232
247
  "instagram": [
@@ -237,8 +252,13 @@ STYLES = {
237
252
  "note": (
238
253
  "Caption is shorter than mk0r (1400-1900 chars). 'here is a story.' "
239
254
  "opener optional. MUST include 'studyly.io' footer. "
240
- "project_name='studyly'. Lesson is always rereading-is-theater. "
241
- "Fires when TARGET=product AND selected_project=studyly."
255
+ "project_name='studyly'. Lesson is always rereading-is-theater. NO "
256
+ "specific before/after exam scores or 'failed -> passed/topped the "
257
+ "class' miracle jumps presented as a typical/guaranteed outcome -- "
258
+ "exaggerated-results claims are a deceptive-practices signal (same "
259
+ "Meta rail that restricted mk0r 2026-06-02); keep outcomes "
260
+ "qualitative and personal. Fires when TARGET=product AND "
261
+ "selected_project=studyly."
242
262
  ),
243
263
  "target_chars": 1650,
244
264
  },
@@ -159,6 +159,21 @@ def main():
159
159
  # product->organic fallback this preserves cadence without weakening
160
160
  # the cooldown.
161
161
  other = "product" if target == "organic" else "organic"
162
+ # ...but ONLY if the other type is still eligible (config weight > 0).
163
+ # A type explicitly disabled via post_type_weights=0 (e.g. product
164
+ # paused during an IG link-sharing restriction) must NEVER be posted
165
+ # through the empty-queue fallback, otherwise weight=0 is not a real
166
+ # off switch. If the only eligible type's queue is empty, exit 2
167
+ # (nothing to post this fire) rather than leak the disabled type.
168
+ if other not in eligible:
169
+ sys.stderr.write(
170
+ f"queue empty for eligible type '{target}'; fallback type "
171
+ f"'{other}' is disabled (config_weights={type_weights_cfg}), "
172
+ "not leaking it"
173
+ + (f" (target_account={account})" if account else "")
174
+ + "\n"
175
+ )
176
+ sys.exit(2)
162
177
  if other == "product":
163
178
  row = _pick_product_with_cooldown()
164
179
  else: