@m13v/s4l 1.7.9-rc.2 → 1.7.9
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/version.json +2 -2
- package/mcp/manifest.json +8 -5
- package/mcp/menubar/s4l_browser_foreground.py +6 -8
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/install.sh +11 -3
- package/scripts/link_tail.py +270 -73
- package/scripts/release-mcpb.sh +84 -62
- package/scripts/twitter_gen_links.py +99 -22
- package/scripts/twitter_post_plan.py +80 -31
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.9
|
|
5
|
+
"version": "1.7.9",
|
|
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": {
|
|
@@ -10,14 +10,17 @@
|
|
|
10
10
|
"email": "i@m13v.com",
|
|
11
11
|
"url": "https://s4l.ai"
|
|
12
12
|
},
|
|
13
|
+
"compatibility": {
|
|
14
|
+
"platforms": [
|
|
15
|
+
"darwin"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
13
18
|
"server": {
|
|
14
19
|
"type": "node",
|
|
15
20
|
"entry_point": "dist/index.js",
|
|
16
21
|
"mcp_config": {
|
|
17
|
-
"command": "${__dirname}/
|
|
18
|
-
"args": [
|
|
19
|
-
"${__dirname}/dist/index.js"
|
|
20
|
-
],
|
|
22
|
+
"command": "${__dirname}/launch.js",
|
|
23
|
+
"args": [],
|
|
21
24
|
"env": {
|
|
22
25
|
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
23
26
|
}
|
|
@@ -158,14 +158,12 @@ class _Worker(threading.Thread):
|
|
|
158
158
|
# unaffected (CDP is offscreen-raster + synthetic input; the occlusion
|
|
159
159
|
# flags keep hidden tabs painting).
|
|
160
160
|
# DEFAULT ON (user decision 2026-08-07, reverses the 2026-07-30 default-off
|
|
161
|
-
# call)
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
if os.environ.get("S4L_NO_HARNESS_HIDE"):
|
|
168
|
-
return
|
|
161
|
+
# call). ONE flag by design (2026-08-08 — the old S4L_NO_HARNESS_HIDE
|
|
162
|
+
# override was removed to end the double-flag confusion):
|
|
163
|
+
# S4L_HARNESS_HIDE unset / any value except "0" => hide active (default)
|
|
164
|
+
# S4L_HARNESS_HIDE=0 => disabled — the single
|
|
165
|
+
# off switch (e.g. to keep a harness window visible for a one-time
|
|
166
|
+
# interactive login, then set it back to 1 / unset it).
|
|
169
167
|
if os.environ.get("S4L_HARNESS_HIDE", "1") == "0":
|
|
170
168
|
return
|
|
171
169
|
pos = details.get("window_position") or ""
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.9
|
|
3
|
+
"version": "1.7.9",
|
|
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/install.sh
CHANGED
|
@@ -32,7 +32,12 @@ log() { echo "[s4l-install] $*"; }
|
|
|
32
32
|
die() { echo "[s4l-install] ERROR: $*" >&2; exit 1; }
|
|
33
33
|
|
|
34
34
|
[ "$(uname -s)" = "Darwin" ] || die "macOS only for now."
|
|
35
|
-
|
|
35
|
+
# The bundle vendors both darwin-arm64 and darwin-x64 Node binaries (1.7.9+);
|
|
36
|
+
# pick the one matching this machine.
|
|
37
|
+
case "$(uname -m)" in
|
|
38
|
+
x86_64|amd64) NODE_ARCH="x64" ;;
|
|
39
|
+
*) NODE_ARCH="arm64" ;;
|
|
40
|
+
esac
|
|
36
41
|
|
|
37
42
|
URL="$STABLE_URL"
|
|
38
43
|
[ "${S4L_CHANNEL:-}" = "staging" ] && URL="$STAGING_URL"
|
|
@@ -45,7 +50,9 @@ log "downloading $URL"
|
|
|
45
50
|
mkdir -p "$TMP/app"
|
|
46
51
|
/usr/bin/unzip -q "$TMP/s4l.mcpb" -d "$TMP/app" || die "unzip failed"
|
|
47
52
|
[ -f "$TMP/app/dist/index.js" ] || die "bundle missing dist/index.js (bad download?)"
|
|
48
|
-
|
|
53
|
+
# Older bundles (pre-1.7.9) only vendor arm64; accept either so a staging box
|
|
54
|
+
# pinned to an old release still installs on Apple Silicon.
|
|
55
|
+
[ -x "$TMP/app/vendor/node-darwin-$NODE_ARCH/bin/node" ] || [ -x "$TMP/app/vendor/node-darwin-arm64/bin/node" ] || die "bundle missing vendored node for $NODE_ARCH"
|
|
49
56
|
|
|
50
57
|
mkdir -p "$STATE_DIR"
|
|
51
58
|
rm -rf "$APP_DIR.new"
|
|
@@ -55,7 +62,8 @@ mv "$APP_DIR.new" "$APP_DIR"
|
|
|
55
62
|
rm -rf "$APP_DIR.old"
|
|
56
63
|
log "installed bundle to $APP_DIR"
|
|
57
64
|
|
|
58
|
-
NODE="$APP_DIR/vendor/node-darwin
|
|
65
|
+
NODE="$APP_DIR/vendor/node-darwin-$NODE_ARCH/bin/node"
|
|
66
|
+
[ -x "$NODE" ] || NODE="$APP_DIR/vendor/node-darwin-arm64/bin/node"
|
|
59
67
|
SERVER="$APP_DIR/dist/index.js"
|
|
60
68
|
SAFE_PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
61
69
|
|
package/scripts/link_tail.py
CHANGED
|
@@ -35,6 +35,18 @@ Usage (CLI / from twitter_post_plan.py):
|
|
|
35
35
|
--project "studyly" \\
|
|
36
36
|
--platform "twitter"
|
|
37
37
|
|
|
38
|
+
# 'name' CTA arm (twitter-tail-cta experiment, launched 2026-08-08): end
|
|
39
|
+
# the bridge with the PLAIN product name instead of a tracked URL. No link,
|
|
40
|
+
# not clickable — intentional. Requires --product-name (or a resolvable
|
|
41
|
+
# config.json entry for --project); no --link-url is used.
|
|
42
|
+
python3 link_tail.py \\
|
|
43
|
+
--reply-text "Step 2 CK was the one that burned me out worst..." \\
|
|
44
|
+
--cta-mode name \\
|
|
45
|
+
--product-name "Studyly" \\
|
|
46
|
+
--thread-text "huge milestone, just passed step 2..." \\
|
|
47
|
+
--project "studyly" \\
|
|
48
|
+
--platform "twitter"
|
|
49
|
+
|
|
38
50
|
Stdout (single JSON object):
|
|
39
51
|
{"ok": true, "text": "<reply_text with bridge tail + URL>",
|
|
40
52
|
"tail": "<just the bridge sentence with URL>",
|
|
@@ -137,6 +149,26 @@ def enforce_budget(text: str, link_url: str,
|
|
|
137
149
|
return _trim_to_chars(text, limit), True
|
|
138
150
|
|
|
139
151
|
|
|
152
|
+
def enforce_budget_name(text: str, product_name: str,
|
|
153
|
+
limit: int = TWEET_LIMIT) -> tuple[str, bool]:
|
|
154
|
+
"""Name-arm analogue of enforce_budget: guarantee x_weighted_len(text) <=
|
|
155
|
+
limit by trimming the BODY, never the trailing plain product name. Unlike
|
|
156
|
+
the URL arm there is no flat t.co weighting — the product name counts as
|
|
157
|
+
its own real length. Returns (text, was_trimmed)."""
|
|
158
|
+
if x_weighted_len(text) <= limit:
|
|
159
|
+
return text, False
|
|
160
|
+
stripped = text.rstrip()
|
|
161
|
+
if product_name and stripped.endswith(product_name):
|
|
162
|
+
head = stripped[: stripped.rfind(product_name)].rstrip()
|
|
163
|
+
# Budget left for the body after reserving the product name + a joining
|
|
164
|
+
# space.
|
|
165
|
+
max_head = limit - x_weighted_len(product_name) - 1
|
|
166
|
+
trimmed_head = _trim_to_chars(head, max_head)
|
|
167
|
+
return (trimmed_head + " " + product_name).strip(), True
|
|
168
|
+
# No product name at the tail (shouldn't happen): hard word-trim.
|
|
169
|
+
return _trim_to_chars(text, limit), True
|
|
170
|
+
|
|
171
|
+
|
|
140
172
|
def _load_project_entry(project: str) -> dict:
|
|
141
173
|
"""Load the matched project's raw config.json entry (case-insensitive
|
|
142
174
|
name match), or {} if config.json is unreadable or the project is
|
|
@@ -174,6 +206,28 @@ def resolve_learned_preferences_block(project_entry: dict) -> str:
|
|
|
174
206
|
no entries yet."""
|
|
175
207
|
return learned_preferences.prompt_block(project_entry)
|
|
176
208
|
|
|
209
|
+
|
|
210
|
+
def resolve_product_name(project_entry: dict, override: str | None,
|
|
211
|
+
project: str = "") -> str:
|
|
212
|
+
"""Plain-text product name for the 'name' CTA arm (twitter-tail-cta).
|
|
213
|
+
|
|
214
|
+
Priority: an explicit --product-name override, then the config.json
|
|
215
|
+
entry's `product_name`, then its `name`, then a title-cased fallback
|
|
216
|
+
derived from the --project arg (so we always have SOMETHING to plant even
|
|
217
|
+
for a project missing from config.json). The name arm has nothing to
|
|
218
|
+
append otherwise, so this deliberately never returns empty when a project
|
|
219
|
+
string is present.
|
|
220
|
+
"""
|
|
221
|
+
if override and override.strip():
|
|
222
|
+
return override.strip()
|
|
223
|
+
pn = (project_entry.get("product_name") or "").strip()
|
|
224
|
+
if pn:
|
|
225
|
+
return pn
|
|
226
|
+
nm = (project_entry.get("name") or "").strip()
|
|
227
|
+
if nm:
|
|
228
|
+
return nm
|
|
229
|
+
return (project or "").replace("_", " ").replace("-", " ").strip().title()
|
|
230
|
+
|
|
177
231
|
# Paths to the Claude Code CLI in order of preference. run_claude.sh resolves
|
|
178
232
|
# `claude` from PATH; we fall back to a direct nvm path if PATH lookup fails
|
|
179
233
|
# (twitter_post_plan.py is invoked from a launchd shell that may have a thin
|
|
@@ -207,9 +261,18 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
|
|
|
207
261
|
project: str, platform: str,
|
|
208
262
|
voice_relationship: str = "first_party",
|
|
209
263
|
learned_prefs_block: str = "",
|
|
210
|
-
bridge_budget: int | None = None
|
|
264
|
+
bridge_budget: int | None = None,
|
|
265
|
+
cta_mode: str = "url", product_name: str = "") -> str:
|
|
211
266
|
"""Compose the one-shot prompt for the bridge sentence ONLY.
|
|
212
267
|
|
|
268
|
+
`cta_mode` selects how the bridge ENDS (twitter-tail-cta experiment,
|
|
269
|
+
launched 2026-08-08):
|
|
270
|
+
- "url" (default): the sentence ends with the tracked landing-page URL,
|
|
271
|
+
the historical behavior — unchanged, byte-for-byte.
|
|
272
|
+
- "name": the sentence ends with the PLAIN product name (`product_name`)
|
|
273
|
+
as ordinary text, NOT a link. No URL, no "http", no domain. This plants
|
|
274
|
+
brand awareness without spending a raw tracked link's engagement cost.
|
|
275
|
+
|
|
213
276
|
The model is never asked to reproduce or rewrite the reply already
|
|
214
277
|
drafted; it's shown that text for context and told explicitly not to
|
|
215
278
|
repeat it. main() appends whatever comes back to the UNMODIFIED
|
|
@@ -256,6 +319,21 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
|
|
|
256
319
|
f" - \"we ship the same recall-on-revisit pattern in {project}, scores against a 4-axis rubric, {link_url}\""
|
|
257
320
|
)
|
|
258
321
|
|
|
322
|
+
# Name arm: replace the URL-ending example sentences with product-NAME-ending
|
|
323
|
+
# ones (voice-matched, same form). The bridge must END with the plain
|
|
324
|
+
# product name as ordinary text \u2014 no URL anywhere.
|
|
325
|
+
if cta_mode == "name":
|
|
326
|
+
if voice_relationship == "third_party":
|
|
327
|
+
example_block = (
|
|
328
|
+
f" - \"fwiw the rubric scoring on rephrased stems is what {project} ships, {product_name}\"\n"
|
|
329
|
+
f" - \"same recall-on-revisit pattern, scored against a 4-axis rubric, that's {product_name}\""
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
example_block = (
|
|
333
|
+
f" - \"fwiw the rubric scoring on rephrased stems is what we built {product_name} for\"\n"
|
|
334
|
+
f" - \"we ship the same recall-on-revisit pattern, scored against a 4-axis rubric, in {product_name}\""
|
|
335
|
+
)
|
|
336
|
+
|
|
259
337
|
# X-specific hard length budget. The reply's body is FIXED (never resized
|
|
260
338
|
# by the model); only the bridge sentence's own budget matters, computed
|
|
261
339
|
# by the caller from how much room the already-drafted body leaves.
|
|
@@ -263,14 +341,23 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
|
|
|
263
341
|
# no tight cap there.
|
|
264
342
|
length_rule = ""
|
|
265
343
|
if platform == "twitter" and bridge_budget is not None:
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
344
|
+
if cta_mode == "name":
|
|
345
|
+
length_rule = (
|
|
346
|
+
f"HARD LENGTH LIMIT for YOUR SENTENCE ONLY:\n"
|
|
347
|
+
f"- Everything you write, INCLUDING the product name at the end, "
|
|
348
|
+
f"must be \u2264 {max(bridge_budget, 0)} characters. The reply above is "
|
|
349
|
+
f"fixed length; you are not resizing it, only adding a short "
|
|
350
|
+
f"sentence after it.\n"
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
length_rule = (
|
|
354
|
+
f"HARD LENGTH LIMIT for YOUR SENTENCE ONLY (X counts EVERY link as "
|
|
355
|
+
f"exactly {URL_WEIGHT} characters, no matter how long it looks; "
|
|
356
|
+
f"that's already reserved below):\n"
|
|
357
|
+
f"- Everything you write BEFORE the URL must be \u2264 {max(bridge_budget, 0)} "
|
|
358
|
+
f"characters. The reply above is fixed length; you are not resizing "
|
|
359
|
+
f"it, only adding a short sentence after it.\n"
|
|
360
|
+
)
|
|
274
361
|
|
|
275
362
|
learned_prefs_section = ""
|
|
276
363
|
if learned_prefs_block:
|
|
@@ -284,6 +371,42 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
|
|
|
284
371
|
"your job here.\n"
|
|
285
372
|
)
|
|
286
373
|
|
|
374
|
+
if cta_mode == "name":
|
|
375
|
+
return f"""You are writing ONLY the bridge sentence that gets APPENDED after a social media reply we already drafted (shown below). This is a one-shot task. Output ONLY the bridge sentence itself: no preamble, no explanation, no quotes, and do NOT repeat, paraphrase, or rewrite any part of the reply below \u2014 it is already final and will be appended before your output automatically, verbatim.
|
|
376
|
+
|
|
377
|
+
PLATFORM: {platform}
|
|
378
|
+
PROJECT: {project}
|
|
379
|
+
PRODUCT NAME: {product_name}
|
|
380
|
+
|
|
381
|
+
{voice_rule}
|
|
382
|
+
|
|
383
|
+
{learned_prefs_section}
|
|
384
|
+
|
|
385
|
+
{length_rule}
|
|
386
|
+
|
|
387
|
+
ORIGINAL THREAD WE ARE REPLYING TO:
|
|
388
|
+
{thread_text}
|
|
389
|
+
|
|
390
|
+
REPLY ALREADY DRAFTED (fixed, DO NOT repeat or modify \u2014 your sentence is appended after this automatically):
|
|
391
|
+
{reply_text}
|
|
392
|
+
|
|
393
|
+
YOUR TASK:
|
|
394
|
+
Write ONE bridge sentence (\u2264 22 words) that:
|
|
395
|
+
1. References the SINGLE strongest specific claim, mechanism, or detail from the reply above (e.g. "rephrasing on revisit", "a 4-axis rubric", "200ms p95", "automatic distractor scoring") \u2014 pick ONE concrete thing, not a category.
|
|
396
|
+
2. Names a CONCRETE PRODUCT MECHANISM that delivers it (verb + noun, inferred from the project context). Do NOT say "a tool for this", "something that helps", "made this for it" \u2014 those are banned.
|
|
397
|
+
3. Ends with the plain product name "{product_name}" written as ORDINARY TEXT \u2014 NOT a link. This is intentional and required: do NOT include any URL, any "http" or "https", any "www", and no domain (nothing that looks like "name.com" / "name.io"). No "click here", "check it out", "give it a try".
|
|
398
|
+
4. Reads in the voice of the reply above (lowercase if reply is lowercase, casual if reply is casual).
|
|
399
|
+
5. Obeys the VOICE RELATIONSHIP rule above. This rule overrides any default phrasing instinct.
|
|
400
|
+
6. Obeys the LEARNED PREFERENCES above when present. This rule overrides the example sentences below on conflict.
|
|
401
|
+
|
|
402
|
+
OUTPUT FORMAT (strict):
|
|
403
|
+
Output ONLY your bridge sentence, ending in the plain product name "{product_name}", on a single line. Nothing else: no JSON, no markdown, no quotes, no URL, and absolutely none of the reply text shown above.
|
|
404
|
+
|
|
405
|
+
Example bridge sentences (do NOT copy verbatim \u2014 these are FORM examples, voice-matched to this project, each ending in the product NAME, no link):
|
|
406
|
+
{example_block}
|
|
407
|
+
|
|
408
|
+
Write the bridge sentence now."""
|
|
409
|
+
|
|
287
410
|
return f"""You are writing ONLY the bridge sentence that gets APPENDED after a social media reply we already drafted (shown below). This is a one-shot task. Output ONLY the bridge sentence itself: no preamble, no explanation, no quotes, and do NOT repeat, paraphrase, or rewrite any part of the reply below \u2014 it is already final and will be appended before your output automatically, verbatim.
|
|
288
411
|
|
|
289
412
|
PLATFORM: {platform}
|
|
@@ -465,9 +588,15 @@ THIRD_PARTY_VOICE_VIOLATIONS = (
|
|
|
465
588
|
)
|
|
466
589
|
|
|
467
590
|
|
|
468
|
-
|
|
591
|
+
# Name-arm URL guards: the 'name' CTA must NOT contain any link. Reject a
|
|
592
|
+
# full URL or a bare domain (so the model can't sneak the tracked link back in
|
|
593
|
+
# under the product-name arm).
|
|
594
|
+
_BARE_DOMAIN_RE = re.compile(r"\b[a-z0-9-]+\.[a-z]{2,}\b", re.IGNORECASE)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def passes_quality_gate(bridge: str, mention: str, *,
|
|
469
598
|
voice_relationship: str = "first_party",
|
|
470
|
-
) -> tuple[bool, str]:
|
|
599
|
+
cta_mode: str = "url") -> tuple[bool, str]:
|
|
471
600
|
"""Return (passes, reason_if_not).
|
|
472
601
|
|
|
473
602
|
Validates the model-written BRIDGE SENTENCE ALONE. reply_text is trusted,
|
|
@@ -475,28 +604,53 @@ def passes_quality_gate(bridge: str, link_url: str,
|
|
|
475
604
|
the append-only contract means reply_text can't fail this gate, only the
|
|
476
605
|
newly generated bridge can.
|
|
477
606
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
607
|
+
`mention` is the CTA target the bridge must end with: the landing-page URL
|
|
608
|
+
in url mode, the plain product name in name mode.
|
|
609
|
+
|
|
610
|
+
url-mode hard rules (unchanged):
|
|
611
|
+
- must contain the URL
|
|
612
|
+
- must end with the URL (allow trailing whitespace, nothing else)
|
|
481
613
|
- must NOT contain banned phrases
|
|
482
|
-
- must not be wildly longer than a sentence (MAX_BRIDGE_CHARS)
|
|
483
|
-
model ignored the "bridge sentence only" contract and is attempting
|
|
484
|
-
a full rewrite — a structural violation, not a phrasing nitpick
|
|
614
|
+
- must not be wildly longer than a sentence (MAX_BRIDGE_CHARS)
|
|
485
615
|
- on third_party projects, must NOT use first-person-plural product
|
|
486
|
-
ownership phrases (
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
616
|
+
ownership phrases (root cause of the 2026-05-27 Agora OODAO incident).
|
|
617
|
+
|
|
618
|
+
name-mode hard rules:
|
|
619
|
+
- must CONTAIN the product name
|
|
620
|
+
- must NOT contain any URL or bare domain (the model may not smuggle the
|
|
621
|
+
tracked link back into the no-link arm)
|
|
622
|
+
- same banned-phrase, third_party-voice, and MAX_BRIDGE_CHARS checks.
|
|
491
623
|
"""
|
|
492
624
|
if not bridge:
|
|
493
625
|
return (False, "empty")
|
|
494
|
-
|
|
626
|
+
|
|
627
|
+
if cta_mode == "name":
|
|
628
|
+
if mention and mention not in bridge:
|
|
629
|
+
return (False, "no_product_name")
|
|
630
|
+
if re.search(r"https?://", bridge, re.IGNORECASE):
|
|
631
|
+
return (False, "url_in_name_arm")
|
|
632
|
+
if _BARE_DOMAIN_RE.search(bridge):
|
|
633
|
+
return (False, "domain_in_name_arm")
|
|
634
|
+
lower = bridge.lower()
|
|
635
|
+
for phrase in BANNED_PHRASES:
|
|
636
|
+
if phrase in lower:
|
|
637
|
+
return (False, f"banned_phrase: {phrase!r}")
|
|
638
|
+
if voice_relationship == "third_party":
|
|
639
|
+
for rx in THIRD_PARTY_VOICE_VIOLATIONS:
|
|
640
|
+
m = rx.search(bridge)
|
|
641
|
+
if m:
|
|
642
|
+
return (False, f"third_party_voice_violation: {m.group(0)!r}")
|
|
643
|
+
if len(bridge) > MAX_BRIDGE_CHARS:
|
|
644
|
+
return (False, f"bridge_too_long:{len(bridge)}>{MAX_BRIDGE_CHARS}")
|
|
645
|
+
return (True, "")
|
|
646
|
+
|
|
647
|
+
# url mode (mention == link_url) — behavior unchanged.
|
|
648
|
+
if mention not in bridge:
|
|
495
649
|
return (False, "no_url")
|
|
496
650
|
# Trailing-URL check (nothing meaningful after URL, optional ./! is fine
|
|
497
651
|
# to strip; but our prompt forbids trailing period — so just check no
|
|
498
652
|
# alphanumeric content follows).
|
|
499
|
-
tail = bridge.split(
|
|
653
|
+
tail = bridge.split(mention, 1)[1].strip()
|
|
500
654
|
if tail and re.search(r"[A-Za-z0-9]", tail):
|
|
501
655
|
return (False, f"content_after_url: {tail[:40]!r}")
|
|
502
656
|
lower = bridge.lower()
|
|
@@ -519,12 +673,26 @@ def mechanical_fallback(reply_text: str, link_url: str) -> str:
|
|
|
519
673
|
return f"{reply_text} {link_url}".strip() if link_url else reply_text
|
|
520
674
|
|
|
521
675
|
|
|
676
|
+
def mechanical_fallback_name(reply_text: str, product_name: str) -> str:
|
|
677
|
+
"""Name-arm analogue of mechanical_fallback: append the plain product name
|
|
678
|
+
(no URL) when the model call is skipped or fails."""
|
|
679
|
+
return f"{reply_text} {product_name}".strip() if product_name else reply_text
|
|
680
|
+
|
|
681
|
+
|
|
522
682
|
def main() -> int:
|
|
523
683
|
ap = argparse.ArgumentParser()
|
|
524
684
|
ap.add_argument("--reply-text", required=True,
|
|
525
685
|
help="The reply we already drafted (no link).")
|
|
526
|
-
ap.add_argument("--link-url",
|
|
527
|
-
help="The landing page URL to fold in.")
|
|
686
|
+
ap.add_argument("--link-url", default="",
|
|
687
|
+
help="The landing page URL to fold in (url mode).")
|
|
688
|
+
ap.add_argument("--cta-mode", default="url", choices=["url", "name"],
|
|
689
|
+
help="How the bridge ends: 'url' (tracked landing-page "
|
|
690
|
+
"link, default) or 'name' (plain product name, no "
|
|
691
|
+
"URL — twitter-tail-cta experiment).")
|
|
692
|
+
ap.add_argument("--product-name", default=None,
|
|
693
|
+
help="Override the product name used in the 'name' CTA "
|
|
694
|
+
"arm. Defaults to config.json product_name/name for "
|
|
695
|
+
"--project, else a title-cased --project slug.")
|
|
528
696
|
ap.add_argument("--thread-text", default="",
|
|
529
697
|
help="The original thread / tweet we are replying to.")
|
|
530
698
|
ap.add_argument("--project", required=True,
|
|
@@ -542,19 +710,51 @@ def main() -> int:
|
|
|
542
710
|
help="Skip run_claude.sh; call claude directly. For testing.")
|
|
543
711
|
args = ap.parse_args()
|
|
544
712
|
|
|
713
|
+
cta_mode = args.cta_mode
|
|
545
714
|
reply_text = (args.reply_text or "").strip()
|
|
546
715
|
link_url = (args.link_url or "").strip()
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
716
|
+
project_entry = _load_project_entry(args.project)
|
|
717
|
+
product_name = ""
|
|
718
|
+
if cta_mode == "name":
|
|
719
|
+
product_name = resolve_product_name(
|
|
720
|
+
project_entry, args.product_name, args.project)
|
|
721
|
+
# `mention` is the CTA target the bridge must end with, threaded through
|
|
722
|
+
# every output/fallback path: the URL in url mode, the product name in
|
|
723
|
+
# name mode.
|
|
724
|
+
mention = product_name if cta_mode == "name" else link_url
|
|
725
|
+
|
|
726
|
+
def _mech() -> str:
|
|
727
|
+
return (mechanical_fallback_name(reply_text, product_name)
|
|
728
|
+
if cta_mode == "name"
|
|
729
|
+
else mechanical_fallback(reply_text, link_url))
|
|
730
|
+
|
|
731
|
+
def _enforce(text: str, lim: int) -> tuple[str, bool]:
|
|
732
|
+
if cta_mode == "name":
|
|
733
|
+
return enforce_budget_name(text, product_name, lim)
|
|
734
|
+
return enforce_budget(text, link_url, lim)
|
|
735
|
+
|
|
736
|
+
def _emit(out: dict) -> None:
|
|
737
|
+
# Stamp the CTA mode on every object; name mode also carries the
|
|
738
|
+
# product name under "mention" for downstream logging.
|
|
739
|
+
out["cta_mode"] = cta_mode
|
|
740
|
+
if cta_mode == "name":
|
|
741
|
+
out["mention"] = product_name
|
|
742
|
+
print(json.dumps(out), flush=True)
|
|
743
|
+
|
|
744
|
+
# url mode requires reply_text + link_url; name mode requires
|
|
745
|
+
# reply_text + product_name.
|
|
746
|
+
missing = (not reply_text) or (
|
|
747
|
+
(not product_name) if cta_mode == "name" else (not link_url))
|
|
748
|
+
if missing:
|
|
749
|
+
# Garbage in → mechanical concat (respects an empty CTA target).
|
|
750
|
+
_emit({
|
|
550
751
|
"ok": True,
|
|
551
|
-
"text":
|
|
552
|
-
"tail":
|
|
752
|
+
"text": _mech(),
|
|
753
|
+
"tail": mention,
|
|
553
754
|
"model_call_ok": False,
|
|
554
755
|
"fallback_used": True,
|
|
555
756
|
"error": "missing_input",
|
|
556
|
-
}
|
|
557
|
-
print(json.dumps(out), flush=True)
|
|
757
|
+
})
|
|
558
758
|
return 0
|
|
559
759
|
|
|
560
760
|
# Plugin (MCP approve_drafts) flow sets S4L_SKIP_LINK_TAIL=1. The bridge only
|
|
@@ -567,50 +767,50 @@ def main() -> int:
|
|
|
567
767
|
# the bridge sentence.
|
|
568
768
|
if os.environ.get("S4L_SKIP_LINK_TAIL") == "1":
|
|
569
769
|
limit = TWEET_LIMIT if args.platform == "twitter" else None
|
|
570
|
-
fb_text, fb_trim =
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
out = {
|
|
770
|
+
fb_text, fb_trim = _enforce(
|
|
771
|
+
_mech(), limit if limit is not None else TWEET_LIMIT * 100)
|
|
772
|
+
_emit({
|
|
574
773
|
"ok": True,
|
|
575
774
|
"text": fb_text,
|
|
576
|
-
"tail":
|
|
775
|
+
"tail": mention,
|
|
577
776
|
"model_call_ok": False,
|
|
578
777
|
"fallback_used": True,
|
|
579
778
|
"budget_trimmed": fb_trim,
|
|
580
779
|
"error": "skipped_plugin_flow",
|
|
581
780
|
"elapsed_sec": 0.0,
|
|
582
|
-
}
|
|
583
|
-
print(json.dumps(out), flush=True)
|
|
781
|
+
})
|
|
584
782
|
return 0
|
|
585
783
|
|
|
586
|
-
project_entry = _load_project_entry(args.project)
|
|
587
784
|
voice_relationship = args.voice_relationship or resolve_voice_relationship(project_entry)
|
|
588
785
|
learned_prefs_block = resolve_learned_preferences_block(project_entry)
|
|
589
786
|
# Length cap is X-specific; reddit/linkedin pass None (no tail trim).
|
|
590
787
|
limit = TWEET_LIMIT if args.platform == "twitter" else None
|
|
591
788
|
|
|
592
789
|
# Deterministic no-room guard: if the already-drafted reply alone leaves
|
|
593
|
-
# no meaningful space for a bridge sentence before the
|
|
594
|
-
#
|
|
595
|
-
#
|
|
596
|
-
#
|
|
790
|
+
# no meaningful space for a bridge sentence before the CTA target, skip
|
|
791
|
+
# the Claude call outright — a squeezed-in fragment is worse than no
|
|
792
|
+
# bridge — and fall back to the mechanical concat like any other failure
|
|
793
|
+
# path. url mode reserves the flat 23-char URL weight; name mode reserves
|
|
794
|
+
# the product name's real length.
|
|
597
795
|
bridge_budget = None
|
|
598
796
|
if limit is not None:
|
|
599
|
-
|
|
797
|
+
if cta_mode == "name":
|
|
798
|
+
bridge_budget = (limit - x_weighted_len(product_name) - 1
|
|
799
|
+
- x_weighted_len(reply_text))
|
|
800
|
+
else:
|
|
801
|
+
bridge_budget = limit - URL_WEIGHT - 1 - x_weighted_len(reply_text)
|
|
600
802
|
if bridge_budget < MIN_BRIDGE_CHARS:
|
|
601
|
-
fb_text, fb_trim =
|
|
602
|
-
|
|
603
|
-
out = {
|
|
803
|
+
fb_text, fb_trim = _enforce(_mech(), limit)
|
|
804
|
+
_emit({
|
|
604
805
|
"ok": True,
|
|
605
806
|
"text": fb_text,
|
|
606
|
-
"tail":
|
|
807
|
+
"tail": mention,
|
|
607
808
|
"model_call_ok": False,
|
|
608
809
|
"fallback_used": True,
|
|
609
810
|
"budget_trimmed": fb_trim,
|
|
610
811
|
"error": f"no_room_for_bridge:{bridge_budget}",
|
|
611
812
|
"elapsed_sec": 0.0,
|
|
612
|
-
}
|
|
613
|
-
print(json.dumps(out), flush=True)
|
|
813
|
+
})
|
|
614
814
|
return 0
|
|
615
815
|
|
|
616
816
|
prompt = build_prompt(
|
|
@@ -620,6 +820,7 @@ def main() -> int:
|
|
|
620
820
|
voice_relationship=voice_relationship,
|
|
621
821
|
learned_prefs_block=learned_prefs_block,
|
|
622
822
|
bridge_budget=bridge_budget,
|
|
823
|
+
cta_mode=cta_mode, product_name=product_name,
|
|
623
824
|
)
|
|
624
825
|
|
|
625
826
|
started = time.time()
|
|
@@ -628,41 +829,38 @@ def main() -> int:
|
|
|
628
829
|
elapsed = round(time.time() - started, 2)
|
|
629
830
|
|
|
630
831
|
if not ok:
|
|
631
|
-
fb_text, fb_trim =
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
out = {
|
|
832
|
+
fb_text, fb_trim = _enforce(
|
|
833
|
+
_mech(), limit if limit is not None else TWEET_LIMIT * 100)
|
|
834
|
+
_emit({
|
|
635
835
|
"ok": True,
|
|
636
836
|
"text": fb_text,
|
|
637
|
-
"tail":
|
|
837
|
+
"tail": mention,
|
|
638
838
|
"model_call_ok": False,
|
|
639
839
|
"fallback_used": True,
|
|
640
840
|
"budget_trimmed": fb_trim,
|
|
641
841
|
"error": err or "model_call_failed",
|
|
642
842
|
"elapsed_sec": elapsed,
|
|
643
|
-
}
|
|
644
|
-
print(json.dumps(out), flush=True)
|
|
843
|
+
})
|
|
645
844
|
return 0
|
|
646
845
|
|
|
647
846
|
bridge = clean_output(raw)
|
|
648
|
-
passes, reason = passes_quality_gate(
|
|
649
|
-
|
|
847
|
+
passes, reason = passes_quality_gate(
|
|
848
|
+
bridge, mention, voice_relationship=voice_relationship,
|
|
849
|
+
cta_mode=cta_mode)
|
|
650
850
|
if not passes:
|
|
651
|
-
fb_text, fb_trim =
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
out = {
|
|
851
|
+
fb_text, fb_trim = _enforce(
|
|
852
|
+
_mech(), limit if limit is not None else TWEET_LIMIT * 100)
|
|
853
|
+
_emit({
|
|
655
854
|
"ok": True,
|
|
656
855
|
"text": fb_text,
|
|
657
|
-
"tail":
|
|
856
|
+
"tail": mention,
|
|
658
857
|
"model_call_ok": True,
|
|
659
858
|
"fallback_used": True,
|
|
660
859
|
"budget_trimmed": fb_trim,
|
|
661
860
|
"error": f"quality_gate_failed:{reason}",
|
|
662
861
|
"raw_model_output": raw[:500],
|
|
663
862
|
"elapsed_sec": elapsed,
|
|
664
|
-
}
|
|
665
|
-
print(json.dumps(out), flush=True)
|
|
863
|
+
})
|
|
666
864
|
return 0
|
|
667
865
|
|
|
668
866
|
# Successful path: append the model-written bridge to the UNMODIFIED
|
|
@@ -674,12 +872,12 @@ def main() -> int:
|
|
|
674
872
|
if limit is not None:
|
|
675
873
|
# Belt-and-suspenders: should be a no-op given the bridge_budget
|
|
676
874
|
# precheck above, unless the model ignored the length rule. Trims
|
|
677
|
-
# from the END of the pre-
|
|
875
|
+
# from the END of the pre-CTA text (the model's own bridge, appended
|
|
678
876
|
# last), so a well-behaved reply_text is only touched in the
|
|
679
877
|
# degenerate case where reply_text alone already exceeds the limit.
|
|
680
|
-
final_text, budget_trimmed =
|
|
878
|
+
final_text, budget_trimmed = _enforce(final_text, limit)
|
|
681
879
|
|
|
682
|
-
|
|
880
|
+
_emit({
|
|
683
881
|
"ok": True,
|
|
684
882
|
"text": final_text,
|
|
685
883
|
"tail": bridge,
|
|
@@ -687,8 +885,7 @@ def main() -> int:
|
|
|
687
885
|
"fallback_used": False,
|
|
688
886
|
"budget_trimmed": budget_trimmed,
|
|
689
887
|
"elapsed_sec": elapsed,
|
|
690
|
-
}
|
|
691
|
-
print(json.dumps(out), flush=True)
|
|
888
|
+
})
|
|
692
889
|
return 0
|
|
693
890
|
|
|
694
891
|
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -386,20 +386,18 @@ console.log(' manifest tools ('+tools.length+'): '+tools.map(t=>t.name).join(',
|
|
|
386
386
|
# dependency on Desktop's internal behavior entirely: manifest.json's command now
|
|
387
387
|
# points at this vendored binary's absolute path, so every session type — main
|
|
388
388
|
# chat, agent-mode, any future session type — spawns the SAME real binary.
|
|
389
|
-
# macOS
|
|
390
|
-
#
|
|
389
|
+
# Both macOS arches since 1.7.9: arm64 (every deployed box) AND x64 (Intel Macs
|
|
390
|
+
# still on Tahoe). mcp/launch.js picks the arch-matched binary at spawn time.
|
|
391
391
|
NODE_RUNTIME_VERSION="v24.18.0"
|
|
392
|
-
|
|
393
|
-
NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
|
|
394
|
-
NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
|
|
392
|
+
NODE_RUNTIME_PLATFORMS="darwin-arm64 darwin-x64"
|
|
395
393
|
NODE_CACHE_DIR="$REPO_ROOT/.cache/node-runtime"
|
|
396
|
-
# Can THIS build host execute
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
# an `Exec format error` (exit 126) -- what broke every rc
|
|
400
|
-
# Run the exec-based `--version` sanity checks ONLY when
|
|
401
|
-
# the vendored target; otherwise verify by presence +
|
|
402
|
-
# all a cross-build can honestly assert.
|
|
394
|
+
# Can THIS build host execute a given vendored binary? The release may be cut
|
|
395
|
+
# from CI on linux-x64, or from an arm64 Mac that cannot exec the x64 node
|
|
396
|
+
# natively (Rosetta usually covers it, but do not depend on that). Executing a
|
|
397
|
+
# foreign-arch node is an `Exec format error` (exit 126) -- what broke every rc
|
|
398
|
+
# from 1.7.6-rc.11 on. Run the exec-based `--version` sanity checks ONLY when
|
|
399
|
+
# the host os/arch matches the vendored target; otherwise verify by presence +
|
|
400
|
+
# executable bit, which is all a cross-build can honestly assert.
|
|
403
401
|
case "$(uname -s)" in
|
|
404
402
|
Darwin) HOST_OS="darwin" ;;
|
|
405
403
|
Linux) HOST_OS="linux" ;;
|
|
@@ -410,40 +408,40 @@ case "$(uname -m)" in
|
|
|
410
408
|
x86_64|amd64) HOST_ARCH="x64" ;;
|
|
411
409
|
*) HOST_ARCH="unknown" ;;
|
|
412
410
|
esac
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
411
|
+
host_can_exec() { [[ "$HOST_OS-$HOST_ARCH" == "$1" ]]; }
|
|
412
|
+
for NODE_RUNTIME_PLATFORM in $NODE_RUNTIME_PLATFORMS; do
|
|
413
|
+
NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
|
|
414
|
+
NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
|
|
415
|
+
say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
|
|
416
|
+
if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
|
|
417
|
+
NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
|
|
418
|
+
NODE_CACHED_TARBALL="$NODE_CACHE_DIR/$NODE_TARBALL"
|
|
419
|
+
mkdir -p "$NODE_CACHE_DIR"
|
|
420
|
+
if [[ ! -f "$NODE_CACHED_TARBALL" ]]; then
|
|
421
|
+
say " downloading $NODE_TARBALL (not cached)"
|
|
422
|
+
curl -sL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$NODE_TARBALL" -o "$NODE_CACHED_TARBALL.tmp" \
|
|
423
|
+
|| die "Node download failed"
|
|
424
|
+
mv "$NODE_CACHED_TARBALL.tmp" "$NODE_CACHED_TARBALL"
|
|
425
|
+
else
|
|
426
|
+
say " using cached $NODE_CACHED_TARBALL"
|
|
427
|
+
fi
|
|
428
|
+
rm -rf "$NODE_VENDOR_DIR"
|
|
429
|
+
mkdir -p "$NODE_VENDOR_DIR/bin"
|
|
430
|
+
# Extract ONLY the node binary itself (no npm/npx/corepack — the server is
|
|
431
|
+
# invoked directly as `node dist/index.js`, nothing here ever needs npm).
|
|
432
|
+
tar -xzf "$NODE_CACHED_TARBALL" -O "node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM/bin/node" > "$NODE_VENDOR_BIN" \
|
|
433
|
+
|| die "failed to extract node binary from $NODE_CACHED_TARBALL"
|
|
434
|
+
chmod +x "$NODE_VENDOR_BIN"
|
|
435
|
+
fi
|
|
436
|
+
[[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
|
|
437
|
+
if host_can_exec "$NODE_RUNTIME_PLATFORM"; then
|
|
438
|
+
VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
|
|
439
|
+
[[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
|
|
440
|
+
echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
|
|
428
441
|
else
|
|
429
|
-
|
|
442
|
+
echo " vendored node: present at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1)); --version self-check skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
|
|
430
443
|
fi
|
|
431
|
-
|
|
432
|
-
mkdir -p "$NODE_VENDOR_DIR/bin"
|
|
433
|
-
# Extract ONLY the node binary itself (no npm/npx/corepack — the server is
|
|
434
|
-
# invoked directly as `node dist/index.js`, nothing here ever needs npm).
|
|
435
|
-
tar -xzf "$NODE_CACHED_TARBALL" -O "node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM/bin/node" > "$NODE_VENDOR_BIN" \
|
|
436
|
-
|| die "failed to extract node binary from $NODE_CACHED_TARBALL"
|
|
437
|
-
chmod +x "$NODE_VENDOR_BIN"
|
|
438
|
-
fi
|
|
439
|
-
[[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
|
|
440
|
-
if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
|
|
441
|
-
VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
|
|
442
|
-
[[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
|
|
443
|
-
echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
|
|
444
|
-
else
|
|
445
|
-
echo " vendored node: present at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1)); --version self-check skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
|
|
446
|
-
fi
|
|
444
|
+
done
|
|
447
445
|
|
|
448
446
|
# ---- 4. Pack the .mcpb ------------------------------------------------------
|
|
449
447
|
say "Packing $BUNDLE"
|
|
@@ -493,31 +491,55 @@ for sub in mcp/dist/version.json mcp/package.json; do
|
|
|
493
491
|
echo " pipeline.tgz $sub: $SUBV ok"
|
|
494
492
|
done
|
|
495
493
|
|
|
496
|
-
|
|
494
|
+
REQUIRED_FILES="dist/index.js dist/runtime.js manifest.json launch.js"
|
|
495
|
+
for p in $NODE_RUNTIME_PLATFORMS; do
|
|
496
|
+
REQUIRED_FILES="$REQUIRED_FILES vendor/node-$p/bin/node"
|
|
497
|
+
done
|
|
498
|
+
for f in $REQUIRED_FILES; do
|
|
497
499
|
# grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
|
|
498
500
|
# gutter so node_modules/.../dist/index.js does not false-match the top-level.
|
|
499
501
|
n=$(printf '%s\n' "$LISTING" | grep -c "[0-9:] $f\$" || true)
|
|
500
502
|
[[ "$n" -ge 1 ]] || die "bundle missing $f"
|
|
501
503
|
done
|
|
502
|
-
echo " runtime + server + manifest + vendored node: ok"
|
|
504
|
+
echo " runtime + server + manifest + launcher + vendored node (both arches): ok"
|
|
505
|
+
|
|
506
|
+
# The compat gate inside Claude Desktop only recognizes a node-type extension
|
|
507
|
+
# when mcp_config.command is the bare string "node" or a path ending in .js
|
|
508
|
+
# (its fo() command-recognizer). Pointing at the vendored node BINARY shipped
|
|
509
|
+
# "this extension is incompatible" to every Mac without a system Node.js
|
|
510
|
+
# (first hit: new-user install, 2026-08-10). Guard the manifest shape so a
|
|
511
|
+
# future edit cannot silently regress it.
|
|
512
|
+
MANIFEST_CMD=$(unzip -p "$BUNDLE" manifest.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).server.mcp_config.command" 2>/dev/null || echo "?")
|
|
513
|
+
case "$MANIFEST_CMD" in
|
|
514
|
+
node|node.exe|*.js) echo " manifest command Desktop-recognizable ($MANIFEST_CMD): ok" ;;
|
|
515
|
+
*) die "manifest mcp_config.command '$MANIFEST_CMD' is not recognizable by Claude Desktop's node compat gate (must be 'node' or end in .js); Macs without system Node.js will see 'incompatible' at install" ;;
|
|
516
|
+
esac
|
|
503
517
|
|
|
504
|
-
# The vendored
|
|
505
|
-
# intact (
|
|
506
|
-
#
|
|
507
|
-
# refuses to execute, or
|
|
518
|
+
# The vendored binaries and the launcher must survive the zip round-trip with
|
|
519
|
+
# their executable bits intact (extract to a temp dir and run --version where
|
|
520
|
+
# the host arch allows; a permissions regression here would silently produce a
|
|
521
|
+
# Node that Desktop can spawn but that refuses to execute, or a launch.js that
|
|
522
|
+
# agent-mode cannot exec).
|
|
508
523
|
VENDOR_CHECK_DIR="$(mktemp -d)"
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
else
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
524
|
+
for p in $NODE_RUNTIME_PLATFORMS; do
|
|
525
|
+
unzip -q -o "$BUNDLE" "vendor/node-$p/bin/node" -d "$VENDOR_CHECK_DIR"
|
|
526
|
+
VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$p/bin/node"
|
|
527
|
+
[[ -x "$VENDOR_CHECK_BIN" ]] || die "vendored node ($p) in bundle lost its executable bit"
|
|
528
|
+
if host_can_exec "$p"; then
|
|
529
|
+
VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
|
|
530
|
+
[[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node ($p) in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
|
|
531
|
+
echo " vendored node ($p) runs from bundle: $VENDOR_CHECK_VER ok"
|
|
532
|
+
else
|
|
533
|
+
echo " vendored node ($p) survives bundle round-trip with exec bit intact; --version run skipped (host $HOST_OS-$HOST_ARCH cannot exec $p)"
|
|
534
|
+
fi
|
|
535
|
+
done
|
|
536
|
+
unzip -q -o "$BUNDLE" "launch.js" -d "$VENDOR_CHECK_DIR"
|
|
537
|
+
LAUNCH_CHECK="$VENDOR_CHECK_DIR/launch.js"
|
|
538
|
+
[[ -x "$LAUNCH_CHECK" ]] || die "launch.js in bundle lost its executable bit (agent-mode literal spawn would fail)"
|
|
539
|
+
head -c 9 "$LAUNCH_CHECK" | grep -q '^#!/bin/sh' || die "launch.js in bundle is missing its #!/bin/sh shebang"
|
|
540
|
+
node --check "$LAUNCH_CHECK" || die "launch.js in bundle is not valid JavaScript (Desktop built-in-node spawn would fail)"
|
|
541
|
+
rm -rf "$VENDOR_CHECK_DIR"
|
|
542
|
+
echo " launch.js: exec bit + shebang + JS syntax ok"
|
|
521
543
|
|
|
522
544
|
if [[ "$DO_RELEASE" == "0" ]]; then
|
|
523
545
|
say "Done (--no-release). Bundle ready at: $BUNDLE"
|
|
@@ -143,21 +143,64 @@ def parse_last_json_object(text):
|
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
def _tail_link_rate() -> float:
|
|
146
|
+
# "Add a tail at all?" gate. Default 1.0 since 2026-08-08: the old
|
|
147
|
+
# link-vs-no_link A/B concluded that day (no_link won engagement but the
|
|
148
|
+
# link arm bought clicks), and its successor — twitter-tail-cta (link vs
|
|
149
|
+
# plain product name) — assumes EVERY promo post gets a tail, differing
|
|
150
|
+
# only in HOW it ends. So promo posts now always get a tail; the
|
|
151
|
+
# personal_brand lane still forces no tail via its own
|
|
152
|
+
# TWITTER_TAIL_LINK_RATE=0 export, and setting the var to 0.0 anywhere
|
|
153
|
+
# still disables tails entirely.
|
|
154
|
+
#
|
|
146
155
|
# DRAFT_ONLY=1 candidates always go through a human-approval review card
|
|
147
156
|
# (run-draft-and-publish.sh exports DRAFT_ONLY before invoking the cycle
|
|
148
157
|
# that calls this script). Force rate=1.0 there so a hand-approved draft
|
|
149
|
-
# never drops the
|
|
158
|
+
# never drops the tail the user already saw baked into the card text —
|
|
150
159
|
# ported from the old post-time override in mcp/src/index.ts
|
|
151
160
|
# (`TWITTER_TAIL_LINK_RATE: "1.0"` on the approve_drafts path), which no
|
|
152
161
|
# longer fires now that the decision happens here, before the card is
|
|
153
|
-
# ever shown.
|
|
154
|
-
# experiment at the configured rate.
|
|
162
|
+
# ever shown.
|
|
155
163
|
if os.environ.get("DRAFT_ONLY") == "1":
|
|
156
164
|
return 1.0
|
|
157
165
|
try:
|
|
158
|
-
return float(os.environ.get("TWITTER_TAIL_LINK_RATE", "0
|
|
166
|
+
return float(os.environ.get("TWITTER_TAIL_LINK_RATE", "1.0"))
|
|
167
|
+
except ValueError:
|
|
168
|
+
return 1.0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cta_link_share() -> float:
|
|
172
|
+
"""P(link arm | a tail is added) for the twitter-tail-cta experiment
|
|
173
|
+
(link vs plain product name), launched 2026-08-08. The complementary share
|
|
174
|
+
goes to the 'name' arm. Read live from TWITTER_TAIL_CTA_LINK_SHARE so the
|
|
175
|
+
split can be swept without a code change; defaults to 0.5."""
|
|
176
|
+
try:
|
|
177
|
+
v = float(os.environ.get("TWITTER_TAIL_CTA_LINK_SHARE", "0.5"))
|
|
159
178
|
except ValueError:
|
|
160
179
|
return 0.5
|
|
180
|
+
if v < 0.0:
|
|
181
|
+
return 0.0
|
|
182
|
+
if v > 1.0:
|
|
183
|
+
return 1.0
|
|
184
|
+
return v
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolve_product_name(project_name: str) -> str:
|
|
188
|
+
"""Plain-text product name for the 'name' arm of twitter-tail-cta.
|
|
189
|
+
config.json entry's product_name, then its name, then a title-cased form
|
|
190
|
+
of the project slug (so we always have SOMETHING to plant). Mirrors
|
|
191
|
+
link_tail.resolve_product_name."""
|
|
192
|
+
try:
|
|
193
|
+
projects = load_projects()
|
|
194
|
+
except Exception:
|
|
195
|
+
projects = {}
|
|
196
|
+
entry = projects.get(project_name) or {}
|
|
197
|
+
pn = (entry.get("product_name") or "").strip()
|
|
198
|
+
if pn:
|
|
199
|
+
return pn
|
|
200
|
+
nm = (entry.get("name") or "").strip()
|
|
201
|
+
if nm:
|
|
202
|
+
return nm
|
|
203
|
+
return (project_name or "").replace("_", " ").replace("-", " ").strip().title()
|
|
161
204
|
|
|
162
205
|
|
|
163
206
|
def apply_tail_link(candidate: dict, link_url: str) -> None:
|
|
@@ -181,39 +224,72 @@ def apply_tail_link(candidate: dict, link_url: str) -> None:
|
|
|
181
224
|
twitter_post_plan.py can detect this candidate is already finalized and
|
|
182
225
|
skip its own (now fallback-only) tail-link block.
|
|
183
226
|
|
|
227
|
+
2026-08-08 — twitter-tail-cta experiment. The old link-vs-no_link A/B
|
|
228
|
+
concluded; _tail_link_rate() now defaults to 1.0 (a tail is essentially
|
|
229
|
+
always added for promo posts). Once a tail is added, the arm is a coin
|
|
230
|
+
flip between BOTH tails carrying the AI-written bridge:
|
|
231
|
+
- 'link': the bridge ends with the tracked URL (historical behavior).
|
|
232
|
+
- 'name': the bridge ends with the PLAIN product name, no URL — passed
|
|
233
|
+
via --cta-mode name and NEVER appending link_url.
|
|
234
|
+
A rate below 1.0 (or personal_brand's TWITTER_TAIL_LINK_RATE=0) still
|
|
235
|
+
yields the 'no_link' arm, which stamps but doesn't feed the tail-cta card.
|
|
236
|
+
|
|
184
237
|
Idempotent: a candidate that already carries tail_link_variant is a
|
|
185
238
|
no-op. Guards against any re-run of this stage over the same candidate
|
|
186
239
|
(retry, salvage, plan re-merge) re-rolling the decision and re-appending
|
|
187
|
-
|
|
240
|
+
the tail onto a reply_text that already has it baked in.
|
|
188
241
|
"""
|
|
189
242
|
if not link_url or candidate.get("tail_link_variant"):
|
|
190
243
|
return
|
|
191
244
|
rate = _tail_link_rate()
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
245
|
+
if random.random() >= rate:
|
|
246
|
+
# Tail explicitly disabled (rate < 1.0 / personal_brand). Stamp the
|
|
247
|
+
# no_link arm; it does not feed the twitter-tail-cta card.
|
|
248
|
+
candidate["tail_link_variant"] = "no_link"
|
|
195
249
|
print(f"[gen] candidate_id={candidate.get('candidate_id')} "
|
|
196
250
|
f"tail_link: ab_no_link (rate={rate})", flush=True)
|
|
197
251
|
return
|
|
198
252
|
reply_text = (candidate.get("reply_text") or "").strip()
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
"
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
253
|
+
project = candidate.get("matched_project") or ""
|
|
254
|
+
use_link = random.random() < _cta_link_share()
|
|
255
|
+
candidate["tail_link_variant"] = "link" if use_link else "name"
|
|
256
|
+
if use_link:
|
|
257
|
+
product_name = ""
|
|
258
|
+
mech = f"{reply_text} {link_url}".strip()
|
|
259
|
+
cmd = [
|
|
260
|
+
"python3", LINK_TAIL,
|
|
261
|
+
"--reply-text", reply_text,
|
|
262
|
+
"--link-url", link_url,
|
|
263
|
+
"--thread-text", candidate.get("thread_text") or "",
|
|
264
|
+
"--project", project,
|
|
265
|
+
"--platform", "twitter",
|
|
266
|
+
"--cta-mode", "url",
|
|
267
|
+
"--timeout", str(LINK_TAIL_TIMEOUT_SEC),
|
|
268
|
+
]
|
|
269
|
+
else:
|
|
270
|
+
# Name arm: end with the plain product name, NO URL in the reply.
|
|
271
|
+
product_name = _resolve_product_name(project)
|
|
272
|
+
mech = f"{reply_text} {product_name}".strip()
|
|
273
|
+
cmd = [
|
|
274
|
+
"python3", LINK_TAIL,
|
|
275
|
+
"--reply-text", reply_text,
|
|
276
|
+
"--product-name", product_name,
|
|
277
|
+
"--thread-text", candidate.get("thread_text") or "",
|
|
278
|
+
"--project", project,
|
|
279
|
+
"--platform", "twitter",
|
|
280
|
+
"--cta-mode", "name",
|
|
281
|
+
"--timeout", str(LINK_TAIL_TIMEOUT_SEC),
|
|
282
|
+
]
|
|
208
283
|
try:
|
|
209
284
|
r = subprocess.run(cmd, capture_output=True, text=True,
|
|
210
285
|
timeout=LINK_TAIL_TIMEOUT_SEC + 30)
|
|
211
286
|
rc, out = r.returncode, r.stdout
|
|
212
287
|
except subprocess.TimeoutExpired:
|
|
213
|
-
candidate["reply_text"] =
|
|
288
|
+
candidate["reply_text"] = mech
|
|
214
289
|
candidate["link_tail_outcome"] = "hard_timeout"
|
|
215
290
|
print(f"[gen] candidate_id={candidate.get('candidate_id')} "
|
|
216
|
-
f"link_tail: hard_timeout;
|
|
291
|
+
f"link_tail[{candidate['tail_link_variant']}]: hard_timeout; "
|
|
292
|
+
f"keeping mechanical concat", flush=True)
|
|
217
293
|
return
|
|
218
294
|
obj = parse_last_json_object(out) or {}
|
|
219
295
|
if obj.get("text"):
|
|
@@ -225,12 +301,13 @@ def apply_tail_link(candidate: dict, link_url: str) -> None:
|
|
|
225
301
|
else:
|
|
226
302
|
# link_tail.py is supposed to ALWAYS return JSON; if we got nothing,
|
|
227
303
|
# hard-fall-back to the mechanical concat so the card still shows a
|
|
228
|
-
# complete draft (
|
|
229
|
-
candidate["reply_text"] =
|
|
304
|
+
# complete draft (tail still present) instead of a bare reply.
|
|
305
|
+
candidate["reply_text"] = mech
|
|
230
306
|
outcome = f"hard_fallback_no_json:rc={rc}"
|
|
231
307
|
candidate["link_tail_outcome"] = outcome
|
|
232
308
|
print(f"[gen] candidate_id={candidate.get('candidate_id')} "
|
|
233
|
-
f"link_tail: {outcome}
|
|
309
|
+
f"link_tail[{candidate['tail_link_variant']}]: {outcome} "
|
|
310
|
+
f"(elapsed={obj.get('elapsed_sec')}s)", flush=True)
|
|
234
311
|
|
|
235
312
|
|
|
236
313
|
def run_generate(product: str, keyword: str, slug: str) -> tuple[str, str]:
|
|
@@ -119,6 +119,29 @@ LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
|
|
|
119
119
|
CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
|
|
120
120
|
LINK_TAIL = os.path.join(REPO_DIR, "scripts", "link_tail.py")
|
|
121
121
|
|
|
122
|
+
|
|
123
|
+
def _resolve_product_name(project: str) -> str:
|
|
124
|
+
"""Plain-text product name for the 'name' arm of the twitter-tail-cta
|
|
125
|
+
fallback below (mirrors twitter_gen_links._resolve_product_name /
|
|
126
|
+
link_tail.resolve_product_name): config.json entry's product_name, then
|
|
127
|
+
its name, then a title-cased form of the project slug."""
|
|
128
|
+
try:
|
|
129
|
+
with open(os.path.join(REPO_DIR, "config.json")) as f:
|
|
130
|
+
cfg = json.load(f)
|
|
131
|
+
except Exception:
|
|
132
|
+
cfg = {}
|
|
133
|
+
name_lc = (project or "").lower()
|
|
134
|
+
for p in cfg.get("projects", []):
|
|
135
|
+
if (p.get("name") or "").lower() == name_lc:
|
|
136
|
+
pn = (p.get("product_name") or "").strip()
|
|
137
|
+
if pn:
|
|
138
|
+
return pn
|
|
139
|
+
nm = (p.get("name") or "").strip()
|
|
140
|
+
if nm:
|
|
141
|
+
return nm
|
|
142
|
+
break
|
|
143
|
+
return (project or "").replace("_", " ").replace("-", " ").strip().title()
|
|
144
|
+
|
|
122
145
|
# Interpreter every child subprocess (twitter_browser.py reply, log_post.py,
|
|
123
146
|
# campaign_bump.py, link_tail.py) must run under. The reply path is the only
|
|
124
147
|
# Playwright importer in the pipeline, so a bare "python3" here silently
|
|
@@ -632,28 +655,60 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
632
655
|
f"(already finalized at draft time, tail_link_variant={tail_link_variant})",
|
|
633
656
|
flush=True)
|
|
634
657
|
else:
|
|
635
|
-
#
|
|
636
|
-
#
|
|
637
|
-
#
|
|
638
|
-
#
|
|
639
|
-
#
|
|
640
|
-
#
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
658
|
+
# FALLBACK ONLY (candidate reached post time unstamped). Mirrors
|
|
659
|
+
# twitter_gen_links.apply_tail_link. AB TEST — twitter-tail-cta
|
|
660
|
+
# (link vs plain product name), launched 2026-08-08. The prior
|
|
661
|
+
# link-vs-no_link A/B concluded that day (no_link won engagement but the
|
|
662
|
+
# link arm bought clicks); both arms now append the AI-written bridge,
|
|
663
|
+
# differing only in whether it ends with the tracked URL ('link') or the
|
|
664
|
+
# plain product name ('name', no URL).
|
|
665
|
+
# TWITTER_TAIL_LINK_RATE (float 0..1, default 1.0) still gates whether
|
|
666
|
+
# ANY tail is added (0.0 disables tails; personal_brand exports 0);
|
|
667
|
+
# TWITTER_TAIL_CTA_LINK_SHARE (float 0..1, default 0.5) splits link vs
|
|
668
|
+
# name once a tail is added. tail_link_variant is logged to
|
|
669
|
+
# posts.tail_link_variant so the dashboard can compare arms.
|
|
670
|
+
try:
|
|
671
|
+
_tail_link_rate = float(os.environ.get("TWITTER_TAIL_LINK_RATE", "1.0"))
|
|
672
|
+
except ValueError:
|
|
673
|
+
_tail_link_rate = 1.0
|
|
674
|
+
_add_tail = link_url and (random.random() < _tail_link_rate)
|
|
645
675
|
link_tail_outcome = "skipped_no_link"
|
|
646
|
-
if
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
676
|
+
if not _add_tail:
|
|
677
|
+
if link_url:
|
|
678
|
+
# Tail disabled: post the reply as-is (no bridge, no URL).
|
|
679
|
+
tail_link_variant = "no_link"
|
|
680
|
+
link_tail_outcome = "ab_no_link"
|
|
681
|
+
print(f"[post] candidate {cid} link_tail: {link_tail_outcome} "
|
|
682
|
+
f"(tail_link_variant=no_link, rate={_tail_link_rate})", flush=True)
|
|
683
|
+
else:
|
|
684
|
+
try:
|
|
685
|
+
_cta_link_share = float(os.environ.get("TWITTER_TAIL_CTA_LINK_SHARE", "0.5"))
|
|
686
|
+
except ValueError:
|
|
687
|
+
_cta_link_share = 0.5
|
|
688
|
+
_use_link = random.random() < _cta_link_share
|
|
689
|
+
tail_link_variant = "link" if _use_link else "name"
|
|
690
|
+
if _use_link:
|
|
691
|
+
_tail_cmd = [PYTHON, LINK_TAIL,
|
|
692
|
+
"--reply-text", reply_text,
|
|
693
|
+
"--link-url", link_url,
|
|
694
|
+
"--thread-text", thread_text or "",
|
|
695
|
+
"--project", project,
|
|
696
|
+
"--platform", "twitter",
|
|
697
|
+
"--cta-mode", "url",
|
|
698
|
+
"--timeout", "120"]
|
|
699
|
+
_mech = f"{reply_text} {link_url}".strip()
|
|
700
|
+
else:
|
|
701
|
+
_product_name = _resolve_product_name(project)
|
|
702
|
+
_tail_cmd = [PYTHON, LINK_TAIL,
|
|
703
|
+
"--reply-text", reply_text,
|
|
704
|
+
"--product-name", _product_name,
|
|
705
|
+
"--thread-text", thread_text or "",
|
|
706
|
+
"--project", project,
|
|
707
|
+
"--platform", "twitter",
|
|
708
|
+
"--cta-mode", "name",
|
|
709
|
+
"--timeout", "120"]
|
|
710
|
+
_mech = f"{reply_text} {_product_name}".strip()
|
|
711
|
+
rc, out, err = run_subprocess(_tail_cmd, timeout_sec=180)
|
|
657
712
|
tail_obj = parse_last_json_object(out) or {}
|
|
658
713
|
if tail_obj.get("text"):
|
|
659
714
|
full_text = tail_obj["text"]
|
|
@@ -664,17 +719,11 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
664
719
|
else:
|
|
665
720
|
# link_tail.py is supposed to ALWAYS return JSON; if we got
|
|
666
721
|
# nothing, hard-fall-back to the mechanical concat to preserve
|
|
667
|
-
# prior behavior (post still ships,
|
|
668
|
-
full_text =
|
|
722
|
+
# prior behavior (post still ships, tail still on the wire).
|
|
723
|
+
full_text = _mech
|
|
669
724
|
link_tail_outcome = f"hard_fallback_no_json:rc={rc}"
|
|
670
|
-
print(f"[post] candidate {cid} link_tail
|
|
671
|
-
f"(elapsed={tail_obj.get('elapsed_sec')}s)", flush=True)
|
|
672
|
-
elif link_url and not _add_tail_link:
|
|
673
|
-
# No-link arm of the AB test: post the reply text as-is (no CTA bridge,
|
|
674
|
-
# no URL). Log the outcome so the dashboard can tally the arm.
|
|
675
|
-
link_tail_outcome = "ab_no_link"
|
|
676
|
-
print(f"[post] candidate {cid} link_tail: {link_tail_outcome} "
|
|
677
|
-
f"(tail_link_variant=no_link, rate={_tail_link_rate})", flush=True)
|
|
725
|
+
print(f"[post] candidate {cid} link_tail[{tail_link_variant}]: "
|
|
726
|
+
f"{link_tail_outcome} (elapsed={tail_obj.get('elapsed_sec')}s)", flush=True)
|
|
678
727
|
|
|
679
728
|
# URL-wrap the text BEFORE handing it to twitter_browser. The browser
|
|
680
729
|
# script appends the campaign suffix internally; suffixes are plain
|