@m13v/s4l 1.7.4-rc.13 → 1.7.4-rc.16
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 +1 -1
- package/mcp/package.json +1 -1
- package/package.json +2 -2
- package/scripts/active_experiments.py +16 -6
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +107 -34
- package/scripts/enrich_reply_parents.py +68 -22
- package/scripts/generate_daily_human_style.py +31 -7
- package/scripts/invent_styles.py +103 -26
- package/scripts/linkedin_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +12 -0
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/run-twitter-cycle.sh +69 -34
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.4-rc.
|
|
5
|
+
"version": "1.7.4-rc.16",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.16",
|
|
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": "@m13v/s4l",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.16",
|
|
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",
|
|
@@ -160,4 +160,4 @@
|
|
|
160
160
|
"pg": "^8.20.0",
|
|
161
161
|
"ws": "^8.0.0"
|
|
162
162
|
}
|
|
163
|
-
}
|
|
163
|
+
}
|
|
@@ -55,15 +55,24 @@ DESCRIPTIONS = {
|
|
|
55
55
|
),
|
|
56
56
|
},
|
|
57
57
|
"draft_prompt": {
|
|
58
|
+
"treatment_v3": (
|
|
59
|
+
"style-as-form: the assigned style is the binding FORM (defining "
|
|
60
|
+
"move + per-style length + self-check), learned preferences apply "
|
|
61
|
+
"inside it; keeps the v2 skeleton ban"
|
|
62
|
+
),
|
|
63
|
+
"control_v3": (
|
|
64
|
+
"plain draft directive, uniform length clamp, no structure ban"
|
|
65
|
+
),
|
|
66
|
+
# v2 arms (skeleton-ban) retired 2026-07-10; v1 arms
|
|
67
|
+
# (decoupled-product-pivot) retired 2026-07-06; kept so any
|
|
68
|
+
# straggler card from an old plan still explains itself.
|
|
58
69
|
"treatment_v2": (
|
|
59
|
-
"skeleton ban
|
|
70
|
+
"v2, retired: skeleton ban, forbids the concede-then-reverse "
|
|
60
71
|
'"easy X / hard Y" structure and forces varied entry points'
|
|
61
72
|
),
|
|
62
73
|
"control_v2": (
|
|
63
|
-
"
|
|
74
|
+
"v2, retired: draft directive (style + product pivot), no structure ban"
|
|
64
75
|
),
|
|
65
|
-
# v1 arms (decoupled-product-pivot) retired 2026-07-06; kept so any
|
|
66
|
-
# straggler card from an old plan still explains itself.
|
|
67
76
|
"treatment": "v1, retired: product pivot decoupled from the reply",
|
|
68
77
|
"control": "v1, retired: original draft directive",
|
|
69
78
|
},
|
|
@@ -113,8 +122,9 @@ def collect(env=None):
|
|
|
113
122
|
if var.startswith(ENV_PREFIX) and (v or "").strip():
|
|
114
123
|
out[var[len(ENV_PREFIX):].lower()] = v.strip()
|
|
115
124
|
# 2026-07-06: the personal_brand persona directive is now ARM-AWARE in
|
|
116
|
-
# run-twitter-cycle.sh (
|
|
117
|
-
#
|
|
125
|
+
# run-twitter-cycle.sh (treatment_v3 adds the skeleton ban + two-layer
|
|
126
|
+
# style/preferences contract, control_v3 does not), so the assigned
|
|
127
|
+
# draft_prompt arm DOES touch persona
|
|
118
128
|
# drafts. Keep it stamped so the arm surfaces on persona cards and the per-arm
|
|
119
129
|
# readout covers both lanes. (Previously dropped here because the persona
|
|
120
130
|
# directive overrode both arms wholesale; that is no longer the case.)
|
|
@@ -144,6 +144,59 @@ def _render_media_block(media) -> str:
|
|
|
144
144
|
)
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
def _build_chain_block(row) -> str:
|
|
148
|
+
"""Conversation chain reconstructed from replies.parent_reply_id linkage.
|
|
149
|
+
|
|
150
|
+
Walks ancestors bottom-up via GET /api/v1/replies/:id and renders the
|
|
151
|
+
chain root-first, each hop showing the inbound comment and (when we
|
|
152
|
+
responded) our reply. Empty string when the row has no parent linkage:
|
|
153
|
+
the root post itself already rides PENDING_DATA via the posts JOIN
|
|
154
|
+
(our_content / thread_title), so a chain block would add nothing.
|
|
155
|
+
|
|
156
|
+
Like counterparty_history_block, the block is self-titled and lands
|
|
157
|
+
inline in PENDING_DATA — no shell-side prompt change needed.
|
|
158
|
+
"""
|
|
159
|
+
parent_id = row.get("parent_reply_id")
|
|
160
|
+
if not parent_id:
|
|
161
|
+
return ""
|
|
162
|
+
hops = []
|
|
163
|
+
seen = set()
|
|
164
|
+
cur = parent_id
|
|
165
|
+
for _ in range(10):
|
|
166
|
+
if not cur or cur in seen:
|
|
167
|
+
break
|
|
168
|
+
seen.add(cur)
|
|
169
|
+
try:
|
|
170
|
+
resp = api_get(f"/api/v1/replies/{cur}")
|
|
171
|
+
except Exception:
|
|
172
|
+
break
|
|
173
|
+
r = (resp.get("data") or {}).get("reply") or {}
|
|
174
|
+
if not r:
|
|
175
|
+
break
|
|
176
|
+
hops.append(r)
|
|
177
|
+
cur = r.get("parent_reply_id")
|
|
178
|
+
if not hops:
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
def _one_line(text):
|
|
182
|
+
return " ".join((text or "").split())
|
|
183
|
+
|
|
184
|
+
lines = [
|
|
185
|
+
"## Conversation chain (reconstructed from our DB; root first — "
|
|
186
|
+
"the row you are drafting for replies to the LAST message)"
|
|
187
|
+
]
|
|
188
|
+
for r in reversed(hops):
|
|
189
|
+
lines.append(f"@{r.get('their_author') or '?'}: {_one_line(r.get('their_content'))}")
|
|
190
|
+
ours = _one_line(r.get("our_reply_content"))
|
|
191
|
+
if ours:
|
|
192
|
+
lines.append(f" our reply: {ours}")
|
|
193
|
+
lines.append(
|
|
194
|
+
f"@{row.get('their_author') or '?'}: {_one_line(row.get('their_content'))}"
|
|
195
|
+
" <- you are replying to this"
|
|
196
|
+
)
|
|
197
|
+
return "\n".join(lines)
|
|
198
|
+
|
|
199
|
+
|
|
147
200
|
def cmd_pending_data(batch_size: int) -> int:
|
|
148
201
|
try:
|
|
149
202
|
from account_resolver import resolve as _resolve_account # noqa: WPS433
|
|
@@ -173,38 +226,50 @@ def cmd_pending_data(batch_size: int) -> int:
|
|
|
173
226
|
# top slot then and get enriched.
|
|
174
227
|
ENRICH_TOP_N = 60
|
|
175
228
|
history_blocks = [""] * len(rows)
|
|
229
|
+
chain_blocks = [""] * len(rows)
|
|
176
230
|
try:
|
|
177
231
|
from concurrent.futures import ThreadPoolExecutor
|
|
178
232
|
from counterparty_history import get_counterparty_history_block
|
|
179
233
|
|
|
180
234
|
def _enrich(r):
|
|
181
235
|
author = r.get("their_author")
|
|
182
|
-
|
|
183
|
-
|
|
236
|
+
history = ""
|
|
237
|
+
if author:
|
|
238
|
+
try:
|
|
239
|
+
_disengage, history = get_counterparty_history_block(
|
|
240
|
+
platform="x",
|
|
241
|
+
author=author,
|
|
242
|
+
current_post_id=r.get("post_id"),
|
|
243
|
+
current_reply_id=r.get("id"),
|
|
244
|
+
)
|
|
245
|
+
history = history or ""
|
|
246
|
+
except Exception as e:
|
|
247
|
+
print(
|
|
248
|
+
f"[engage_twitter_helper] counterparty_history failed "
|
|
249
|
+
f"for @{author}: {e}",
|
|
250
|
+
file=sys.stderr,
|
|
251
|
+
)
|
|
184
252
|
try:
|
|
185
|
-
|
|
186
|
-
platform="x",
|
|
187
|
-
author=author,
|
|
188
|
-
current_post_id=r.get("post_id"),
|
|
189
|
-
current_reply_id=r.get("id"),
|
|
190
|
-
)
|
|
191
|
-
return block or ""
|
|
253
|
+
chain = _build_chain_block(r)
|
|
192
254
|
except Exception as e:
|
|
193
255
|
print(
|
|
194
|
-
f"[engage_twitter_helper]
|
|
195
|
-
f"for
|
|
256
|
+
f"[engage_twitter_helper] chain block failed "
|
|
257
|
+
f"for reply {r.get('id')}: {e}",
|
|
196
258
|
file=sys.stderr,
|
|
197
259
|
)
|
|
198
|
-
|
|
260
|
+
chain = ""
|
|
261
|
+
return (history, chain)
|
|
199
262
|
|
|
200
263
|
top_rows = rows[:ENRICH_TOP_N]
|
|
201
264
|
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
202
|
-
for idx,
|
|
203
|
-
history_blocks[idx] =
|
|
265
|
+
for idx, (history, chain) in enumerate(ex.map(_enrich, top_rows)):
|
|
266
|
+
history_blocks[idx] = history
|
|
267
|
+
chain_blocks[idx] = chain
|
|
204
268
|
non_empty = sum(1 for b in history_blocks if b)
|
|
269
|
+
chains_non_empty = sum(1 for b in chain_blocks if b)
|
|
205
270
|
print(
|
|
206
|
-
f"[engage_twitter_helper]
|
|
207
|
-
f"
|
|
271
|
+
f"[engage_twitter_helper] enriched {len(top_rows)}/{len(rows)} rows "
|
|
272
|
+
f"(history={non_empty}, chain={chains_non_empty} non-empty)",
|
|
208
273
|
file=sys.stderr,
|
|
209
274
|
)
|
|
210
275
|
except Exception as e:
|
|
@@ -215,7 +280,7 @@ def cmd_pending_data(batch_size: int) -> int:
|
|
|
215
280
|
)
|
|
216
281
|
|
|
217
282
|
out = []
|
|
218
|
-
for r, history_block in zip(rows, history_blocks):
|
|
283
|
+
for r, history_block, chain_block in zip(rows, history_blocks, chain_blocks):
|
|
219
284
|
out.append({
|
|
220
285
|
"id": r.get("id"),
|
|
221
286
|
"platform": r.get("platform"),
|
|
@@ -231,6 +296,7 @@ def cmd_pending_data(batch_size: int) -> int:
|
|
|
231
296
|
"is_our_original_post": int(r.get("is_our_original_post") or 0),
|
|
232
297
|
"project_name": r.get("project_name"),
|
|
233
298
|
"counterparty_history_block": history_block,
|
|
299
|
+
"conversation_chain_block": chain_block,
|
|
234
300
|
"their_media_block": _render_media_block(r.get("their_media")),
|
|
235
301
|
})
|
|
236
302
|
# json_agg(...) returns null when the array is empty; engage-twitter.sh's
|
|
@@ -1464,38 +1464,53 @@ def get_assigned_style_prompt(platform, assignment, context="posting"):
|
|
|
1464
1464
|
lines = []
|
|
1465
1465
|
|
|
1466
1466
|
if assignment["mode"] == "use":
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
lines.append(
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1467
|
+
# Draft-prompt A/B v3 (style-as-form, 2026-07-10): the treatment_v3
|
|
1468
|
+
# arm renders the style as the BINDING FORM of the draft (defining
|
|
1469
|
+
# move + per-style length for EVERY assignment + end-of-block
|
|
1470
|
+
# self-check + two-layer learned_preferences contract). The arm is
|
|
1471
|
+
# read from the env HERE because this block is rendered inside the
|
|
1472
|
+
# cycle process where run-twitter-cycle.sh assigns and exports
|
|
1473
|
+
# S4L_DRAFT_PROMPT_VARIANT (stamp-at-source: that same process also
|
|
1474
|
+
# stamps the arm onto every plan candidate via active_experiments).
|
|
1475
|
+
# Downstream/post-time consumers never re-read this env. Unset or
|
|
1476
|
+
# control_v3 (and every non-twitter caller, which never has the env)
|
|
1477
|
+
# renders the legacy block below unchanged.
|
|
1478
|
+
_dp_arm = (os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or "").strip()
|
|
1479
|
+
if _dp_arm == "treatment_v3":
|
|
1480
|
+
_tc = assignment.get("target_chars") or DEFAULT_TARGET_CHARS
|
|
1481
|
+
lines.append(
|
|
1482
|
+
f"## Your assigned engagement style: **{assignment['style']}** "
|
|
1483
|
+
"(this is the FORM of the draft, not a flavor hint)"
|
|
1484
|
+
)
|
|
1485
|
+
lines.append("")
|
|
1486
|
+
lines.append(
|
|
1487
|
+
f"This style was selected by the picker (weighted by live "
|
|
1488
|
+
f"click-driven performance across {platform}). Use it. Do not "
|
|
1489
|
+
f"swap it for a different listed style."
|
|
1490
|
+
)
|
|
1491
|
+
lines.append("")
|
|
1492
|
+
lines.append(f"Platform tone: {policy.get('note', '')}")
|
|
1493
|
+
lines.append("")
|
|
1494
|
+
lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
|
|
1495
|
+
if assignment.get("example"):
|
|
1496
|
+
lines.append(f' Example: "{assignment["example"]}"')
|
|
1497
|
+
if assignment.get("note"):
|
|
1498
|
+
lines.append(f" Note: {assignment['note']}")
|
|
1499
|
+
lines.append("")
|
|
1500
|
+
lines.append(
|
|
1501
|
+
"Commit to the form BEFORE writing. From the description and "
|
|
1502
|
+
"example above, identify the style's DEFINING MOVE (the one "
|
|
1503
|
+
"thing a draft in this style must contain: a specific number, "
|
|
1504
|
+
"a question, a flat disagreement, a confession, whatever the "
|
|
1505
|
+
"description names) and build the reply around that move. The "
|
|
1506
|
+
"test: with the topic removed, a reader should be able to "
|
|
1507
|
+
"identify this style from the draft's shape alone. If your "
|
|
1508
|
+
"draft would read the same under any other style name, it "
|
|
1509
|
+
"does not conform; rewrite it."
|
|
1510
|
+
)
|
|
1496
1511
|
lines.append("")
|
|
1497
1512
|
lines.append(
|
|
1498
|
-
f"**LENGTH: aim for about {int(
|
|
1513
|
+
f"**LENGTH: aim for about {int(_tc)} characters** "
|
|
1499
1514
|
"(this style's own winning length; within about 30% either "
|
|
1500
1515
|
"way is fine, never above 250). Let the target set the form: "
|
|
1501
1516
|
"a very short target means one clipped line, a long one can "
|
|
@@ -1503,14 +1518,72 @@ def get_assigned_style_prompt(platform, assignment, context="posting"):
|
|
|
1503
1518
|
"This applies to the comment text only; any link/CTA the "
|
|
1504
1519
|
"system appends afterward is separate."
|
|
1505
1520
|
)
|
|
1521
|
+
lines.append("")
|
|
1522
|
+
lines.append(
|
|
1523
|
+
"Learned user preferences (the learned_preferences block in "
|
|
1524
|
+
"the project context) apply INSIDE this form: they control "
|
|
1525
|
+
"voice, wording, and what to avoid; they do not replace the "
|
|
1526
|
+
"style's structure or length. If a preference seems to "
|
|
1527
|
+
"conflict with the style's defining move, keep the move and "
|
|
1528
|
+
"satisfy the preference within it."
|
|
1529
|
+
)
|
|
1530
|
+
lines.append("")
|
|
1531
|
+
lines.append(
|
|
1532
|
+
"SELF-CHECK before returning: (1) does the draft contain the "
|
|
1533
|
+
"style's defining move? (2) is the length within about 30% "
|
|
1534
|
+
"of the target? If either fails, rewrite once."
|
|
1535
|
+
)
|
|
1506
1536
|
else:
|
|
1537
|
+
lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
|
|
1507
1538
|
lines.append("")
|
|
1508
1539
|
lines.append(
|
|
1509
|
-
"
|
|
1510
|
-
"
|
|
1511
|
-
"
|
|
1512
|
-
"link/CTA the system appends afterward is separate."
|
|
1540
|
+
f"This style was selected by the picker (weighted by live "
|
|
1541
|
+
f"click-driven performance across {platform}). Use it. Do not "
|
|
1542
|
+
f"swap it for a different listed style."
|
|
1513
1543
|
)
|
|
1544
|
+
lines.append("")
|
|
1545
|
+
lines.append(f"Platform tone: {policy.get('note', '')}")
|
|
1546
|
+
lines.append("")
|
|
1547
|
+
lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
|
|
1548
|
+
if assignment.get("example"):
|
|
1549
|
+
lines.append(f' Example: "{assignment["example"]}"')
|
|
1550
|
+
if assignment.get("note"):
|
|
1551
|
+
lines.append(f" Note: {assignment['note']}")
|
|
1552
|
+
# LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
|
|
1553
|
+
# uses the legacy generic length guidance. The treatment's per-style
|
|
1554
|
+
# target prompt remains preserved only in the shipped experiment card.
|
|
1555
|
+
#
|
|
1556
|
+
# EXCEPTION (2026-07-11, Draft-B explore slot): exploration
|
|
1557
|
+
# assignments (pick_exploration_style, marked by `source`) honor the
|
|
1558
|
+
# style's own target_chars so the A/B pair diverges on the length
|
|
1559
|
+
# axis too. The uniform clamp flattened every draft to the same
|
|
1560
|
+
# 2-sentence shape (user: "the older drafts looked all very similar
|
|
1561
|
+
# in terms of the length"). The 06-04 conclusion still governs the
|
|
1562
|
+
# scored path, which never carries `source`.
|
|
1563
|
+
#
|
|
1564
|
+
# (The draft-prompt v3 treatment arm above supersedes this clamp
|
|
1565
|
+
# for its cycles; this legacy branch IS the v3 control arm.)
|
|
1566
|
+
_explore_tc = assignment.get("target_chars")
|
|
1567
|
+
if (assignment.get("source") in ("human_derived", "model_invented")
|
|
1568
|
+
and _explore_tc):
|
|
1569
|
+
lines.append("")
|
|
1570
|
+
lines.append(
|
|
1571
|
+
f"**LENGTH: aim for about {int(_explore_tc)} characters** "
|
|
1572
|
+
"(this style's own winning length; within about 30% either "
|
|
1573
|
+
"way is fine, never above 250). Let the target set the form: "
|
|
1574
|
+
"a very short target means one clipped line, a long one can "
|
|
1575
|
+
"breathe. Do NOT default to the usual two-sentence shape. "
|
|
1576
|
+
"This applies to the comment text only; any link/CTA the "
|
|
1577
|
+
"system appends afterward is separate."
|
|
1578
|
+
)
|
|
1579
|
+
else:
|
|
1580
|
+
lines.append("")
|
|
1581
|
+
lines.append(
|
|
1582
|
+
"**LENGTH: keep it tight.** One or two sentences, well under the "
|
|
1583
|
+
"250-character Twitter limit. A short, sharp reply almost always "
|
|
1584
|
+
"beats a paragraph. This applies to the comment text only; any "
|
|
1585
|
+
"link/CTA the system appends afterward is separate."
|
|
1586
|
+
)
|
|
1514
1587
|
lines.append("")
|
|
1515
1588
|
lines.append(
|
|
1516
1589
|
'In your output JSON, set "engagement_style" to exactly '
|
|
@@ -87,11 +87,14 @@ def fetch_fxtwitter(handle, tweet_id):
|
|
|
87
87
|
def walk_ancestors(handle, tweet_id, sleep_s):
|
|
88
88
|
"""Ancestor chain bottom-up: [(id, handle), ...] parent first, root last.
|
|
89
89
|
|
|
90
|
-
Returns (chain, terminal)
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
Returns (focal, chain, terminal): focal is the fetched tweet object for
|
|
91
|
+
tweet_id itself (None when it is gone/unfetchable — needed for quote
|
|
92
|
+
linkage), terminal is 'root' when the walk reached a non-reply tweet,
|
|
93
|
+
'gone'/'transient' when a hop was deleted/protected/errored (the chain up
|
|
94
|
+
to that point is still usable, but root attribution is not), or 'hop_cap'.
|
|
93
95
|
"""
|
|
94
96
|
chain = []
|
|
97
|
+
focal = None
|
|
95
98
|
cur_handle, cur_id = handle, tweet_id
|
|
96
99
|
for _ in range(MAX_HOPS):
|
|
97
100
|
status, tweet = fetch_fxtwitter(cur_handle, cur_id)
|
|
@@ -99,14 +102,16 @@ def walk_ancestors(handle, tweet_id, sleep_s):
|
|
|
99
102
|
if status != "ok":
|
|
100
103
|
# 'gone' is terminal (deleted/protected); 'transient' must NOT be
|
|
101
104
|
# remembered, the next run retries it.
|
|
102
|
-
return chain, status
|
|
105
|
+
return focal, chain, status
|
|
106
|
+
if focal is None:
|
|
107
|
+
focal = tweet
|
|
103
108
|
parent_id = tweet.get("replying_to_status")
|
|
104
109
|
parent_handle = tweet.get("replying_to") or ""
|
|
105
110
|
if not parent_id:
|
|
106
|
-
return chain, "root"
|
|
111
|
+
return focal, chain, "root"
|
|
107
112
|
chain.append((str(parent_id), parent_handle))
|
|
108
113
|
cur_handle, cur_id = parent_handle, parent_id
|
|
109
|
-
return chain, "hop_cap"
|
|
114
|
+
return focal, chain, "hop_cap"
|
|
110
115
|
|
|
111
116
|
|
|
112
117
|
def lookup_our_post(tweet_id):
|
|
@@ -126,6 +131,16 @@ def lookup_tracked_reply(tweet_id):
|
|
|
126
131
|
return rows[0] if rows else None
|
|
127
132
|
|
|
128
133
|
|
|
134
|
+
def lookup_our_posted_reply(tweet_id):
|
|
135
|
+
"""Reply row where WE authored the tweet (our_reply_id / our_reply_url)."""
|
|
136
|
+
resp = api_get(
|
|
137
|
+
"/api/v1/replies",
|
|
138
|
+
query={"platform": "x", "our_reply_status_id": str(tweet_id), "limit": "1"},
|
|
139
|
+
)
|
|
140
|
+
rows = (resp.get("data") or {}).get("replies") or []
|
|
141
|
+
return rows[0] if rows else None
|
|
142
|
+
|
|
143
|
+
|
|
129
144
|
def fetch_work(limit, our_account=None, ids=None, before_id=None):
|
|
130
145
|
if ids:
|
|
131
146
|
out = []
|
|
@@ -158,13 +173,12 @@ def enrich_row(row, state, sleep_s, dry_run):
|
|
|
158
173
|
if state.get(tid):
|
|
159
174
|
return "state_skip"
|
|
160
175
|
|
|
161
|
-
chain, terminal = walk_ancestors(handle, tid, sleep_s)
|
|
162
|
-
if
|
|
176
|
+
focal, chain, terminal = walk_ancestors(handle, tid, sleep_s)
|
|
177
|
+
if focal is None:
|
|
163
178
|
if terminal == "transient":
|
|
164
179
|
return "transient" # retry next run, no state write
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
return state[tid]
|
|
180
|
+
state[tid] = "gone" # deleted/protected focal tweet
|
|
181
|
+
return "gone"
|
|
168
182
|
|
|
169
183
|
patch = {}
|
|
170
184
|
matched_post = None
|
|
@@ -178,24 +192,56 @@ def enrich_row(row, state, sleep_s, dry_run):
|
|
|
178
192
|
if not row.get("project_name") and matched_post.get("project_name"):
|
|
179
193
|
patch["project_name"] = matched_post["project_name"]
|
|
180
194
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
195
|
+
if chain:
|
|
196
|
+
parent_id, _parent_handle = chain[0]
|
|
197
|
+
# Immediate parent: another inbound reply we track, or a reply WE
|
|
198
|
+
# posted (the dominant case: a fan replying to our engagement reply).
|
|
199
|
+
tracked = lookup_tracked_reply(parent_id)
|
|
200
|
+
if tracked and tracked["id"] != rid:
|
|
201
|
+
patch["parent_reply_id"] = tracked["id"]
|
|
202
|
+
patch["depth"] = (tracked.get("depth") or 1) + 1
|
|
203
|
+
else:
|
|
204
|
+
ours = lookup_our_posted_reply(parent_id)
|
|
205
|
+
if ours and ours["id"] != rid:
|
|
206
|
+
patch["parent_reply_id"] = ours["id"]
|
|
207
|
+
patch["depth"] = (ours.get("depth") or 1) + 1
|
|
208
|
+
if "post_id" not in patch and ours.get("post_id"):
|
|
209
|
+
patch["post_id"] = ours["post_id"]
|
|
210
|
+
if not row.get("project_name") and not patch.get("project_name") \
|
|
211
|
+
and ours.get("project_name"):
|
|
212
|
+
patch["project_name"] = ours["project_name"]
|
|
213
|
+
|
|
214
|
+
# Quote linkage: a quote-tweet of our post (or of a reply we posted) is
|
|
215
|
+
# engagement on our content even when replying_to_status is empty.
|
|
216
|
+
quote_id = str(((focal.get("quote") or {}).get("id")) or "")
|
|
217
|
+
if quote_id and "post_id" not in patch:
|
|
218
|
+
qpost = lookup_our_post(quote_id)
|
|
219
|
+
if qpost:
|
|
220
|
+
patch["post_id"] = qpost["id"]
|
|
221
|
+
if not row.get("project_name") and not patch.get("project_name") \
|
|
222
|
+
and qpost.get("project_name"):
|
|
223
|
+
patch["project_name"] = qpost["project_name"]
|
|
224
|
+
elif "parent_reply_id" not in patch:
|
|
225
|
+
qours = lookup_our_posted_reply(quote_id)
|
|
226
|
+
if qours and qours["id"] != rid:
|
|
227
|
+
patch["parent_reply_id"] = qours["id"]
|
|
228
|
+
patch["depth"] = (qours.get("depth") or 1) + 1
|
|
229
|
+
if qours.get("post_id"):
|
|
230
|
+
patch["post_id"] = qours["post_id"]
|
|
186
231
|
|
|
187
232
|
if terminal == "root":
|
|
188
|
-
root_handle = (chain[-1][1]
|
|
189
|
-
|
|
233
|
+
root_handle = (chain[-1][1] if chain else (focal.get("author") or {}).get("screen_name") or "")
|
|
234
|
+
root_handle = (root_handle or "").lstrip("@")
|
|
235
|
+
if chain and root_handle and not row.get("thread_author_handle"):
|
|
190
236
|
patch["thread_author_handle"] = root_handle
|
|
191
237
|
|
|
192
238
|
if not patch:
|
|
193
239
|
if terminal == "transient":
|
|
194
240
|
return "transient" # incomplete walk; retry next run
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
state[tid] = "no_link"
|
|
198
|
-
return
|
|
241
|
+
# Chain walked to root / cut by a deleted ancestor / not a reply at
|
|
242
|
+
# all, and nothing of ours anywhere in it: remember permanently.
|
|
243
|
+
state[tid] = "no_link" if chain else "not_a_reply"
|
|
244
|
+
return state[tid]
|
|
199
245
|
|
|
200
246
|
if dry_run:
|
|
201
247
|
print(f" [DRY] reply {rid}: {json.dumps(patch)}")
|
|
@@ -193,11 +193,23 @@ def build_prompt(platform, replies, reserved_names):
|
|
|
193
193
|
lines.append("")
|
|
194
194
|
lines.append(
|
|
195
195
|
"These replies all WON the thread (top of the conversation by likes). "
|
|
196
|
-
"Find the shared pattern that makes them work
|
|
196
|
+
"Find the shared pattern that makes them work: the rhetorical move, "
|
|
197
197
|
"the structural shape, the relationship to the OP. Most winners "
|
|
198
198
|
"share ONE pattern; that pattern is your new engagement style."
|
|
199
199
|
)
|
|
200
200
|
lines.append("")
|
|
201
|
+
lines.append(
|
|
202
|
+
"PRESERVE THE STRUCTURAL FINGERPRINT. Do not smooth the pattern "
|
|
203
|
+
"into a generic one-liner ('add a thoughtful counterpoint'); keep "
|
|
204
|
+
"what the winning replies actually DO on the page: how the first "
|
|
205
|
+
"words enter (lowercase noun, a number, the question itself, a "
|
|
206
|
+
"quoted phrase), how many sentences and of what shape (one clipped "
|
|
207
|
+
"line, two short + one long, a fragment), where the punch sits, "
|
|
208
|
+
"and what the reply refuses to do (no greeting, no hedge, no "
|
|
209
|
+
"summary). The char counts shown per reply are part of the "
|
|
210
|
+
"fingerprint; notice where the winners cluster."
|
|
211
|
+
)
|
|
212
|
+
lines.append("")
|
|
201
213
|
lines.append(
|
|
202
214
|
"Ignore: replies that win because of follower count, fame, or "
|
|
203
215
|
"non-repeatable luck. Focus on the structural move that we (a "
|
|
@@ -209,14 +221,15 @@ def build_prompt(platform, replies, reserved_names):
|
|
|
209
221
|
)
|
|
210
222
|
lines.append("")
|
|
211
223
|
for i, r in enumerate(replies, 1):
|
|
224
|
+
content = r.get("reply_content") or ""
|
|
212
225
|
lines.append(
|
|
213
226
|
f"### #{i} (likes={r['likes']}, replies={r['replies_count']}, "
|
|
214
|
-
f"rt={r['retweets']})"
|
|
227
|
+
f"rt={r['retweets']}, chars={len(content.strip())})"
|
|
215
228
|
)
|
|
216
229
|
lines.append(f"Thread: {r['thread_url']}")
|
|
217
230
|
handle = r.get("reply_author_handle") or "(unknown)"
|
|
218
231
|
lines.append(f"Reply by @{handle}:")
|
|
219
|
-
lines.append(f"> {
|
|
232
|
+
lines.append(f"> {content}")
|
|
220
233
|
lines.append("")
|
|
221
234
|
|
|
222
235
|
lines.append("## Schema (match exactly)")
|
|
@@ -230,7 +243,12 @@ def build_prompt(platform, replies, reserved_names):
|
|
|
230
243
|
lines.append("```")
|
|
231
244
|
lines.append("{")
|
|
232
245
|
lines.append(' "name": "<snake_case_name>",')
|
|
233
|
-
lines.append(
|
|
246
|
+
lines.append(
|
|
247
|
+
' "description": "<one to three sentences carrying the structural '
|
|
248
|
+
"fingerprint: the DEFINING MOVE (the one thing every draft in this "
|
|
249
|
+
"style must contain), the OPENING (how the first words enter), and "
|
|
250
|
+
'the sentence shape>",'
|
|
251
|
+
)
|
|
234
252
|
lines.append(' "example": "<one short OP + reply pair demonstrating the style>",')
|
|
235
253
|
lines.append(' "best_in": {')
|
|
236
254
|
lines.append(f' "{platform}": ["<short context label>", ...],')
|
|
@@ -255,8 +273,11 @@ def build_prompt(platform, replies, reserved_names):
|
|
|
255
273
|
"MOVE (e.g. `mirror_and_extend`, `flip_to_alt`, not `good_reply`)."
|
|
256
274
|
)
|
|
257
275
|
lines.append(
|
|
258
|
-
"3. The description
|
|
259
|
-
"reading
|
|
276
|
+
"3. The description must make the style copyable AS A FORM: a future "
|
|
277
|
+
"model reading only the description must know the defining move, how "
|
|
278
|
+
"to open, and what sentence shape to write. 'Add a sharp "
|
|
279
|
+
"counterpoint' fails this test; 'one clipped sentence, no greeting, "
|
|
280
|
+
"opens with the concrete number the OP left out' passes."
|
|
260
281
|
)
|
|
261
282
|
lines.append(
|
|
262
283
|
"4. The example should be a realistic OP + reply pair, not lifted "
|
|
@@ -270,7 +291,10 @@ def build_prompt(platform, replies, reserved_names):
|
|
|
270
291
|
lines.append(
|
|
271
292
|
"6. NEVER propose a style about including a product, a URL, or a "
|
|
272
293
|
"mechanism. Our link-tail layer handles that downstream. The style "
|
|
273
|
-
"is about the text BEFORE the link."
|
|
294
|
+
"is about the text BEFORE the link. The note MAY state in one "
|
|
295
|
+
"clause how a plain product mention would enter this style if ever "
|
|
296
|
+
"(e.g. 'product name fits as the concrete example slot; never in "
|
|
297
|
+
"the opening'), but no URLs or link mechanics."
|
|
274
298
|
)
|
|
275
299
|
return "\n".join(lines)
|
|
276
300
|
|
package/scripts/invent_styles.py
CHANGED
|
@@ -15,12 +15,26 @@ This job runs OUTSIDE any drafting context, on the operator Mac only:
|
|
|
15
15
|
style), so invention must NOT fan out per install the way topic
|
|
16
16
|
invention does. One central daily run is the correct scope; that is
|
|
17
17
|
the deliberate difference from invent_topics.py's per-install kicker.
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
- VERBALIZED SAMPLING (2026-07-10, arXiv 2510.01171): a single "invent
|
|
19
|
+
one style" ask reliably returns the modal answer (typicality bias from
|
|
20
|
+
preference training), which is why 653 of ~950 registered styles
|
|
21
|
+
cluster at 53-98 target_chars and one rhetorical family. Instead the
|
|
22
|
+
prompt asks for N_CANDIDATES candidate styles WITH a verbalized
|
|
23
|
+
typicality probability each, and selection takes the LOWEST-probability
|
|
24
|
+
candidate that survives dedup: we sample the tail of the distribution,
|
|
25
|
+
not the mode. This is the principled fix; the family blocklist below is
|
|
26
|
+
only a backstop.
|
|
27
|
+
- The existing registry is framed as OCCUPIED NICHES to stay out of
|
|
28
|
+
(quality-diversity framing, arXiv 2310.13032), NOT as reference
|
|
29
|
+
inspiration; showing top performers as exemplars is what anchored the
|
|
30
|
+
retired inline path to the champion family.
|
|
31
|
+
- target_chars DIVERSITY: the prompt shows live length-band occupancy
|
|
32
|
+
and requires candidates to spread across bands, biased to the
|
|
33
|
+
least-occupied ones, so invention stops minting 80-char styles.
|
|
21
34
|
- Post-hoc semantic dedup (token-Jaccard on description+example, plus a
|
|
22
|
-
reframe-family heuristic) rejects near-clones;
|
|
23
|
-
with a grown avoid-list, at most DUPE_RETRIES
|
|
35
|
+
reframe-family heuristic) rejects near-clones; when every candidate is
|
|
36
|
+
rejected we re-prompt with a grown avoid-list, at most DUPE_RETRIES
|
|
37
|
+
times.
|
|
24
38
|
- Accepted styles are registered via engagement_styles.register_style
|
|
25
39
|
(kind='model_invented'), same as the retired inline path, so pickers
|
|
26
40
|
see them on their next tick with zero other wiring.
|
|
@@ -52,6 +66,12 @@ SCRIPT_TAG = "invent-styles"
|
|
|
52
66
|
CALL_TIMEOUT_SEC = 420
|
|
53
67
|
DUPE_RETRIES = 3
|
|
54
68
|
SIMILARITY_THRESHOLD = 0.5 # Jaccard on description+example tokens
|
|
69
|
+
N_CANDIDATES = 5 # verbalized-sampling fan-out per call
|
|
70
|
+
|
|
71
|
+
# Length bands for target_chars diversity. Occupancy is computed live from
|
|
72
|
+
# the registry and shown in the prompt; candidates must spread across bands
|
|
73
|
+
# and bias toward the least-occupied ones.
|
|
74
|
+
LENGTH_BANDS = [(30, 60), (60, 100), (100, 150), (150, 200), (200, 250)]
|
|
55
75
|
|
|
56
76
|
# Heuristic markers of the saturated agree-then-relocate/reframe family.
|
|
57
77
|
# A proposal whose description/example leans on these is a clone of the
|
|
@@ -98,6 +118,23 @@ def _find_near_dupe(proposal, universe):
|
|
|
98
118
|
return None
|
|
99
119
|
|
|
100
120
|
|
|
121
|
+
def band_occupancy(universe):
|
|
122
|
+
"""[(lo, hi, count), ...] for LENGTH_BANDS over the registry's
|
|
123
|
+
target_chars. The live histogram the prompt shows so candidates can
|
|
124
|
+
claim underrepresented bands."""
|
|
125
|
+
counts = [0] * len(LENGTH_BANDS)
|
|
126
|
+
for e in universe.values():
|
|
127
|
+
try:
|
|
128
|
+
tc = int(e.get("target_chars") or 0)
|
|
129
|
+
except (TypeError, ValueError):
|
|
130
|
+
continue
|
|
131
|
+
for i, (lo, hi) in enumerate(LENGTH_BANDS):
|
|
132
|
+
if lo <= tc < hi:
|
|
133
|
+
counts[i] += 1
|
|
134
|
+
break
|
|
135
|
+
return [(lo, hi, counts[i]) for i, (lo, hi) in enumerate(LENGTH_BANDS)]
|
|
136
|
+
|
|
137
|
+
|
|
101
138
|
def build_prompt(universe, avoid):
|
|
102
139
|
names = sorted(universe.keys())
|
|
103
140
|
# Detail only a bounded sample (prompt-size guard): the seeds plus the
|
|
@@ -117,25 +154,42 @@ def build_prompt(universe, avoid):
|
|
|
117
154
|
"\nAlready proposed and REJECTED this run (do not resubmit or "
|
|
118
155
|
"paraphrase): " + ", ".join(avoid) + "\n"
|
|
119
156
|
)
|
|
157
|
+
bands = band_occupancy(universe)
|
|
158
|
+
band_lines = "\n".join(
|
|
159
|
+
f"- {lo}-{hi} chars: {count} existing styles"
|
|
160
|
+
f"{' <- UNDERREPRESENTED, prefer this band' if count == min(c for _, _, c in bands) else ''}"
|
|
161
|
+
for lo, hi, count in bands
|
|
162
|
+
)
|
|
120
163
|
return f"""You maintain the engagement-style registry for a social reply system. A style is a named rhetorical TEMPLATE (description + one example reply) that drafters follow when writing short replies on X/Twitter and Reddit.
|
|
121
164
|
|
|
122
|
-
|
|
165
|
+
The registry below is OCCUPIED TERRITORY, not inspiration. Your job is to find empty niches: structural families and length bands that no existing style covers. Do not riff on what is already there.
|
|
166
|
+
|
|
167
|
+
The registry is saturated with ONE structural family: agree-then-relocate (concede the surface point, move the spotlight to the hidden/harder/unmeasured part, often "X is easy, Y is the real work" or "X was never the point, Y is"). Do NOT propose any member of that family, however disguised.
|
|
123
168
|
|
|
124
169
|
EXISTING STYLE NAMES ({len(names)} total):
|
|
125
170
|
{", ".join(names)}
|
|
126
171
|
|
|
127
|
-
REPRESENTATIVE DETAILS (sample):
|
|
172
|
+
REPRESENTATIVE DETAILS (occupied niches, sample):
|
|
128
173
|
{chr(10).join(detailed)}
|
|
174
|
+
|
|
175
|
+
LENGTH-BAND OCCUPANCY (live registry histogram of target_chars):
|
|
176
|
+
{band_lines}
|
|
129
177
|
{avoid_block}
|
|
130
|
-
TASK:
|
|
178
|
+
TASK (verbalized sampling): generate {N_CANDIDATES} CANDIDATE styles. For each, verbalize a `probability`: how likely this exact style would be as a language model's single first answer to "invent a new reply style" (0.0-1.0, honest, they need not sum to 1). Deliberately include tail candidates: at least 3 of the {N_CANDIDATES} must have probability under 0.10, meaning genuinely atypical moves a model would almost never produce first. We will programmatically select from the LOW-probability tail, so the obvious candidates are effectively discards; put your creativity into the tail.
|
|
179
|
+
|
|
180
|
+
Diversity requirements across the {N_CANDIDATES} candidates:
|
|
181
|
+
- Each from a DIFFERENT structural family. Families worth mining (or others you identify): direct answer with zero framing; first-person confession of a specific failure; pure curious question with no thesis; dry understatement one-liner; enthusiastic cosign with one concrete addition; flat disagreement stated plainly without conceding anything first; a tiny numbered checklist; a vivid analogy that does NOT end in a lesson; deadpan humor riffing on the thread's wording.
|
|
182
|
+
- Each in a DIFFERENT length band from the histogram above, biased toward the least-occupied bands. `target_chars` must be the actual length of your example, and the example must genuinely inhabit its band (a 200-char style is narrative, not a padded one-liner).
|
|
131
183
|
|
|
132
|
-
|
|
133
|
-
-
|
|
184
|
+
Per-candidate rules:
|
|
185
|
+
- `description` must state the style's DEFINING MOVE (the one thing every draft in this style must contain) and its OPENING (how the first words enter: e.g. lowercase noun, a number, the question itself). A future model reading only the description must know exactly what shape to write.
|
|
186
|
+
- `note` states when to use / when not to, and how a product mention would enter this style if ever (one clause; the link/CTA layer is downstream, so no URLs or link mechanics).
|
|
187
|
+
- The example must read like a real human reply (lowercase ok), NO links, NO product names.
|
|
134
188
|
- The style must be usable across many products and threads, not thread-specific.
|
|
135
|
-
-
|
|
189
|
+
- Every candidate is dedup-checked by token similarity against every existing style's description+example; if you cannot field {N_CANDIDATES} genuinely different moves, return the saturation envelope instead of forcing paraphrases.
|
|
136
190
|
|
|
137
191
|
Answer with ONLY one JSON object, no prose, in one of these two shapes:
|
|
138
|
-
{{"name": "snake_case_name", "description": "...", "example": "...", "why_existing_didnt_fit": "...", "target_chars": <int
|
|
192
|
+
{{"candidates": [{{"name": "snake_case_name", "description": "...", "example": "...", "note": "...", "why_existing_didnt_fit": "...", "target_chars": <int>, "probability": <float>}}, ...]}}
|
|
139
193
|
{{"saturated": true, "reason": "..."}}"""
|
|
140
194
|
|
|
141
195
|
|
|
@@ -202,21 +256,43 @@ def main():
|
|
|
202
256
|
print(f"[invent_styles] slot={slot} model reports saturation: "
|
|
203
257
|
f"{obj.get('reason', '')[:200]}", file=sys.stderr)
|
|
204
258
|
break
|
|
205
|
-
|
|
206
|
-
if not
|
|
207
|
-
print(f"[invent_styles] slot={slot}
|
|
208
|
-
file=sys.stderr)
|
|
209
|
-
avoid.append(name or "(unnamed)")
|
|
210
|
-
continue
|
|
211
|
-
dupe = _find_near_dupe({**obj, "name": name}, universe)
|
|
212
|
-
if dupe:
|
|
213
|
-
reason, existing = dupe
|
|
214
|
-
print(f"[invent_styles] slot={slot} rejected {name!r}: {reason} "
|
|
215
|
-
f"vs {existing}", file=sys.stderr)
|
|
216
|
-
avoid.append(name)
|
|
259
|
+
candidates = obj.get("candidates")
|
|
260
|
+
if not isinstance(candidates, list) or not candidates:
|
|
261
|
+
print(f"[invent_styles] slot={slot} attempt={attempt} no "
|
|
262
|
+
f"candidates array in output", file=sys.stderr)
|
|
217
263
|
continue
|
|
218
|
-
|
|
219
|
-
|
|
264
|
+
# Verbalized-sampling selection: walk the tail first (ascending
|
|
265
|
+
# verbalized probability = least typical candidate first) and
|
|
266
|
+
# take the first that survives dedup. The modal candidates at
|
|
267
|
+
# the top of the distribution only get a chance if every tail
|
|
268
|
+
# candidate is a clone.
|
|
269
|
+
def _prob(c):
|
|
270
|
+
try:
|
|
271
|
+
return float(c.get("probability"))
|
|
272
|
+
except (TypeError, ValueError):
|
|
273
|
+
return 1.0 # unparseable prob sorts as maximally typical
|
|
274
|
+
for cand in sorted(candidates, key=_prob):
|
|
275
|
+
name = str(cand.get("name") or "").strip()
|
|
276
|
+
if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
|
|
277
|
+
print(f"[invent_styles] slot={slot} bad name {name!r}; "
|
|
278
|
+
f"skipping candidate", file=sys.stderr)
|
|
279
|
+
avoid.append(name or "(unnamed)")
|
|
280
|
+
continue
|
|
281
|
+
dupe = _find_near_dupe({**cand, "name": name}, universe)
|
|
282
|
+
if dupe:
|
|
283
|
+
reason, existing = dupe
|
|
284
|
+
print(f"[invent_styles] slot={slot} rejected {name!r} "
|
|
285
|
+
f"(p={_prob(cand):.2f}): {reason} vs {existing}",
|
|
286
|
+
file=sys.stderr)
|
|
287
|
+
avoid.append(name)
|
|
288
|
+
continue
|
|
289
|
+
accepted = {**cand, "name": name}
|
|
290
|
+
print(f"[invent_styles] slot={slot} selected tail candidate "
|
|
291
|
+
f"{name!r} (p={_prob(cand):.2f}) from "
|
|
292
|
+
f"{len(candidates)} candidates", file=sys.stderr)
|
|
293
|
+
break
|
|
294
|
+
if accepted:
|
|
295
|
+
break
|
|
220
296
|
if not accepted:
|
|
221
297
|
continue
|
|
222
298
|
if args.dry_run:
|
|
@@ -227,6 +303,7 @@ def main():
|
|
|
227
303
|
{
|
|
228
304
|
"description": accepted.get("description", ""),
|
|
229
305
|
"example": accepted.get("example", ""),
|
|
306
|
+
"note": accepted.get("note", ""),
|
|
230
307
|
"why_existing_didnt_fit": accepted.get("why_existing_didnt_fit", ""),
|
|
231
308
|
"target_chars": accepted.get("target_chars"),
|
|
232
309
|
},
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinkedIn posting cadence: 4 active days, then a mandatory 2-day break.
|
|
3
|
+
|
|
4
|
+
Per user instruction (2026-07-11): LinkedIn activity should not run more than
|
|
5
|
+
four days at a time before taking a two-day break. "Active day" is counted
|
|
6
|
+
only when real posting/engagement activity actually happened that day (an
|
|
7
|
+
outage day does not count toward the four), and the break pauses ALL LinkedIn
|
|
8
|
+
traffic, including the passive presence-check job, not just posting.
|
|
9
|
+
|
|
10
|
+
Every LinkedIn entrypoint (skill/run-linkedin.sh, engage-linkedin.sh,
|
|
11
|
+
engage-dm-replies.sh, dm-outreach-linkedin.sh, audit-linkedin.sh,
|
|
12
|
+
linkedin-presence.sh) already gates on the existence of ONE file:
|
|
13
|
+
~/.claude/social-autoposter/linkedin.killswitch
|
|
14
|
+
That file is scripts/linkedin_killswitch.py's antibot killswitch. This module
|
|
15
|
+
reuses the SAME file for a scheduled break (signal="scheduled_break") so every
|
|
16
|
+
entrypoint pauses automatically, with zero edits to the locked entrypoint
|
|
17
|
+
scripts. It never overwrites a REAL antibot signal, and linkedin_killswitch.py
|
|
18
|
+
is patched (recover-check) to never try to auto-recover a scheduled_break.
|
|
19
|
+
|
|
20
|
+
State lives at ~/.claude/social-autoposter/linkedin_cadence.json:
|
|
21
|
+
{
|
|
22
|
+
"phase": "active" | "break",
|
|
23
|
+
"phase_started": "2026-07-11T20:00:00Z",
|
|
24
|
+
"active_days": ["2026-07-13", "2026-07-14", ...] # UTC dates with
|
|
25
|
+
# confirmed posts,
|
|
26
|
+
# only meaningful
|
|
27
|
+
# while phase=active
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
CLI:
|
|
31
|
+
python3 scripts/linkedin_cadence.py enforce # one tick; called every 15m
|
|
32
|
+
python3 scripts/linkedin_cadence.py status # print state (json)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
from datetime import datetime, timedelta, timezone
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
41
|
+
|
|
42
|
+
import http_api # noqa: E402
|
|
43
|
+
import linkedin_killswitch as ks # noqa: E402
|
|
44
|
+
|
|
45
|
+
STATE_DIR = os.path.expanduser(
|
|
46
|
+
os.environ.get("LINKEDIN_KILLSWITCH_DIR", "~/.claude/social-autoposter")
|
|
47
|
+
)
|
|
48
|
+
STATE_FILE = os.path.expanduser(
|
|
49
|
+
os.environ.get("LINKEDIN_CADENCE_FILE", os.path.join(STATE_DIR, "linkedin_cadence.json"))
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
ACTIVE_DAYS_TARGET = int(os.environ.get("LINKEDIN_CADENCE_ACTIVE_DAYS", "4"))
|
|
53
|
+
BREAK_DAYS = int(os.environ.get("LINKEDIN_CADENCE_BREAK_DAYS", "2"))
|
|
54
|
+
|
|
55
|
+
SCHEDULED_BREAK_SIGNAL = "scheduled_break"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _now():
|
|
59
|
+
return datetime.now(timezone.utc)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _now_iso():
|
|
63
|
+
return _now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _today_str():
|
|
67
|
+
return _now().date().isoformat()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_ts(ts):
|
|
71
|
+
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ensure_dir():
|
|
75
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_state():
|
|
79
|
+
try:
|
|
80
|
+
with open(STATE_FILE, "r") as f:
|
|
81
|
+
return json.load(f)
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_state(state):
|
|
87
|
+
_ensure_dir()
|
|
88
|
+
tmp = STATE_FILE + ".tmp"
|
|
89
|
+
with open(tmp, "w") as f:
|
|
90
|
+
json.dump(state, f, indent=2)
|
|
91
|
+
f.write("\n")
|
|
92
|
+
os.replace(tmp, STATE_FILE)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _default_state_starting_break():
|
|
96
|
+
# First-ever run: user asked to start immediately with a two-day break.
|
|
97
|
+
return {"phase": "break", "phase_started": _now_iso(), "active_days": []}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _today_post_count():
|
|
101
|
+
"""LinkedIn posts made today (UTC), via the same API the dashboard uses.
|
|
102
|
+
|
|
103
|
+
Best-effort: returns None on any failure so callers can skip counting
|
|
104
|
+
this tick rather than wrongly recording 0 activity."""
|
|
105
|
+
try:
|
|
106
|
+
resp = http_api.api_get(
|
|
107
|
+
"/api/v1/dashboard/posts-per-day", {"days": 1, "platform": "linkedin"}
|
|
108
|
+
)
|
|
109
|
+
rows = (resp or {}).get("data", {}).get("rows", [])
|
|
110
|
+
today = _today_str()
|
|
111
|
+
for row in rows:
|
|
112
|
+
if row.get("day") == today:
|
|
113
|
+
return int(row.get("posts_made") or 0)
|
|
114
|
+
return 0
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
print(f"[linkedin_cadence] WARN: posts-per-day query failed: {exc}", file=sys.stderr)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _pause_marker_set():
|
|
121
|
+
"""Set the shared killswitch file for a scheduled break, unless a REAL
|
|
122
|
+
antibot signal is already in charge (never stomp a genuine block)."""
|
|
123
|
+
payload = ks.read()
|
|
124
|
+
if payload is None:
|
|
125
|
+
ks.engage(
|
|
126
|
+
signal=SCHEDULED_BREAK_SIGNAL,
|
|
127
|
+
detail="cadence: scheduled 2-day pause after 4 active days",
|
|
128
|
+
send_email=False,
|
|
129
|
+
)
|
|
130
|
+
print("[linkedin_cadence] pause marker set (scheduled_break)", file=sys.stderr)
|
|
131
|
+
elif payload.get("signal") == SCHEDULED_BREAK_SIGNAL:
|
|
132
|
+
pass # already set by us; idempotent, no trail spam
|
|
133
|
+
else:
|
|
134
|
+
print(
|
|
135
|
+
f"[linkedin_cadence] real killswitch already active (signal="
|
|
136
|
+
f"{payload.get('signal')!r}); deferring to it, not overwriting",
|
|
137
|
+
file=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pause_marker_clear_if_ours():
|
|
142
|
+
payload = ks.read()
|
|
143
|
+
if payload is not None and payload.get("signal") == SCHEDULED_BREAK_SIGNAL:
|
|
144
|
+
ks.clear()
|
|
145
|
+
print("[linkedin_cadence] pause marker cleared (break ended)", file=sys.stderr)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def enforce():
|
|
149
|
+
state = load_state() or _default_state_starting_break()
|
|
150
|
+
if load_state() is None:
|
|
151
|
+
save_state(state)
|
|
152
|
+
print(
|
|
153
|
+
f"[linkedin_cadence] no prior state; starting BREAK phase now "
|
|
154
|
+
f"({BREAK_DAYS}d)",
|
|
155
|
+
file=sys.stderr,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
now = _now()
|
|
159
|
+
phase = state["phase"]
|
|
160
|
+
|
|
161
|
+
if phase == "break":
|
|
162
|
+
started = _parse_ts(state["phase_started"])
|
|
163
|
+
elapsed = now - started
|
|
164
|
+
if elapsed >= timedelta(days=BREAK_DAYS):
|
|
165
|
+
state = {"phase": "active", "phase_started": _now_iso(), "active_days": []}
|
|
166
|
+
save_state(state)
|
|
167
|
+
_pause_marker_clear_if_ours()
|
|
168
|
+
print(
|
|
169
|
+
f"[linkedin_cadence] break ended after {elapsed}; switching to ACTIVE",
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
)
|
|
172
|
+
phase = "active"
|
|
173
|
+
else:
|
|
174
|
+
remaining = timedelta(days=BREAK_DAYS) - elapsed
|
|
175
|
+
_pause_marker_set()
|
|
176
|
+
print(
|
|
177
|
+
f"[linkedin_cadence] BREAK phase: {remaining} remaining",
|
|
178
|
+
file=sys.stderr,
|
|
179
|
+
)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# phase == "active"
|
|
183
|
+
real_block = ks.read()
|
|
184
|
+
if real_block is not None and real_block.get("signal") != SCHEDULED_BREAK_SIGNAL:
|
|
185
|
+
print(
|
|
186
|
+
f"[linkedin_cadence] account down for a real reason (signal="
|
|
187
|
+
f"{real_block.get('signal')!r}); not counting today, not pausing",
|
|
188
|
+
file=sys.stderr,
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
count = _today_post_count()
|
|
193
|
+
today = _today_str()
|
|
194
|
+
if count is not None and count > 0 and today not in state["active_days"]:
|
|
195
|
+
state["active_days"].append(today)
|
|
196
|
+
save_state(state)
|
|
197
|
+
print(
|
|
198
|
+
f"[linkedin_cadence] activity confirmed today ({count} posts); "
|
|
199
|
+
f"active_days={len(state['active_days'])}/{ACTIVE_DAYS_TARGET} "
|
|
200
|
+
f"{state['active_days']}",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if len(state["active_days"]) >= ACTIVE_DAYS_TARGET:
|
|
205
|
+
state = {"phase": "break", "phase_started": _now_iso(), "active_days": []}
|
|
206
|
+
save_state(state)
|
|
207
|
+
_pause_marker_set()
|
|
208
|
+
print(
|
|
209
|
+
f"[linkedin_cadence] {ACTIVE_DAYS_TARGET} active days reached; "
|
|
210
|
+
f"switching to BREAK for {BREAK_DAYS}d",
|
|
211
|
+
file=sys.stderr,
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
print(
|
|
215
|
+
f"[linkedin_cadence] ACTIVE phase: "
|
|
216
|
+
f"{len(state['active_days'])}/{ACTIVE_DAYS_TARGET} active days so far",
|
|
217
|
+
file=sys.stderr,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main():
|
|
222
|
+
if len(sys.argv) < 2 or sys.argv[1] not in ("enforce", "status"):
|
|
223
|
+
print("usage: linkedin_cadence.py [enforce|status]", file=sys.stderr)
|
|
224
|
+
sys.exit(2)
|
|
225
|
+
if sys.argv[1] == "status":
|
|
226
|
+
state = load_state()
|
|
227
|
+
print(json.dumps(state if state is not None else {"phase": None}, indent=2))
|
|
228
|
+
sys.exit(0)
|
|
229
|
+
enforce()
|
|
230
|
+
sys.exit(0)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
main()
|
|
@@ -145,6 +145,10 @@ VALID_SIGNALS = {
|
|
|
145
145
|
"session_invalid_marker",
|
|
146
146
|
"captcha_detected",
|
|
147
147
|
"manual",
|
|
148
|
+
# Deliberate pause from scripts/linkedin_cadence.py (4-active-days /
|
|
149
|
+
# 2-day-break schedule). Not an incident: recover-check must never try to
|
|
150
|
+
# auto-recover it, and engage() should never email on it.
|
|
151
|
+
"scheduled_break",
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
|
|
@@ -894,6 +898,14 @@ def _cmd_recover_check(args):
|
|
|
894
898
|
if not is_active():
|
|
895
899
|
print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
|
|
896
900
|
sys.exit(1)
|
|
901
|
+
payload = read() or {}
|
|
902
|
+
if payload.get("signal") == "scheduled_break":
|
|
903
|
+
print(
|
|
904
|
+
"recover-check: scheduled_break active (cadence pause, not a real "
|
|
905
|
+
"incident); deferring to scripts/linkedin_cadence.py, not probing",
|
|
906
|
+
file=sys.stderr,
|
|
907
|
+
)
|
|
908
|
+
sys.exit(1)
|
|
897
909
|
if is_terminal():
|
|
898
910
|
print(
|
|
899
911
|
"recover-check: TERMINAL (auto-recovery gave up); "
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# linkedin-cadence.sh — enforce the 4-active-day / 2-day-break LinkedIn
|
|
3
|
+
# posting schedule (user instruction, 2026-07-11). Fired every 15 minutes by
|
|
4
|
+
# launchd (com.m13v.social-linkedin-cadence). All logic lives in
|
|
5
|
+
# scripts/linkedin_cadence.py; this wrapper just logs the tick.
|
|
6
|
+
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
export PATH="/opt/homebrew/bin:$PATH"
|
|
9
|
+
|
|
10
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
11
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
12
|
+
mkdir -p "$LOG_DIR"
|
|
13
|
+
LOG="$LOG_DIR/linkedin-cadence.log"
|
|
14
|
+
|
|
15
|
+
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG" >&2; }
|
|
16
|
+
|
|
17
|
+
PY="/opt/homebrew/bin/python3"
|
|
18
|
+
[ -x "$PY" ] || PY="/usr/bin/python3"
|
|
19
|
+
|
|
20
|
+
"$PY" "$REPO_DIR/scripts/linkedin_cadence.py" enforce >>"$LOG" 2>&1
|
|
@@ -1725,7 +1725,7 @@ log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)
|
|
|
1725
1725
|
# are actually different (a same-name pair defeats the whole point); INVENT
|
|
1726
1726
|
# mode on either side is accepted immediately since an invented name is
|
|
1727
1727
|
# definitionally distinct from a pinned one. This is orthogonal to the
|
|
1728
|
-
#
|
|
1728
|
+
# treatment_v3/control_v3 draft-prompt A/B below (that varies WORDING of the
|
|
1729
1729
|
# directive for the whole cycle; this varies STYLE per draft slot), so neither
|
|
1730
1730
|
# experiment disturbs the other.
|
|
1731
1731
|
STYLE_ASSIGN_FILE_B=$(mktemp -t s4l_twitter_assign_b_XXXXXX.json)
|
|
@@ -1793,38 +1793,44 @@ fi
|
|
|
1793
1793
|
export S4L_EXP_DRAFT_B_SOURCE="$DRAFT_B_SOURCE"
|
|
1794
1794
|
log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)} source=$DRAFT_B_SOURCE"
|
|
1795
1795
|
|
|
1796
|
-
# --- Draft-prompt A/B:
|
|
1796
|
+
# --- Draft-prompt A/B: style-as-form (v3, 2026-07-10) ------------------------
|
|
1797
1797
|
# Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
|
|
1798
1798
|
# assignment is at cycle granularity, not per post; the whole batch shares it).
|
|
1799
|
-
#
|
|
1800
|
-
#
|
|
1801
|
-
#
|
|
1802
|
-
#
|
|
1803
|
-
#
|
|
1804
|
-
#
|
|
1805
|
-
#
|
|
1806
|
-
#
|
|
1807
|
-
#
|
|
1808
|
-
#
|
|
1809
|
-
#
|
|
1799
|
+
# control_v3 = the plain draft directive (v2's control text verbatim).
|
|
1800
|
+
# treatment_v3 = style-as-FORM package: the assigned engagement style is the
|
|
1801
|
+
# binding FORM of the draft (defining move + per-style length +
|
|
1802
|
+
# end-of-block self-check, rendered arm-aware by
|
|
1803
|
+
# engagement_styles.get_assigned_style_prompt, which reads
|
|
1804
|
+
# S4L_DRAFT_PROMPT_VARIANT at render time IN this cycle
|
|
1805
|
+
# process), PLUS the two-layer learned_preferences contract
|
|
1806
|
+
# (style owns form, preferences own voice inside it; the old
|
|
1807
|
+
# 'overrides on conflict' phrasing made the model treat the
|
|
1808
|
+
# style as optional), PLUS the v2 skeleton ban carried over.
|
|
1809
|
+
# History: v1 (2026-06-29) decoupled the product pivot; the model kept the
|
|
1810
|
+
# skeleton (30% ~= 28% on 857 replies). v2 (2026-07-06) banned the
|
|
1811
|
+
# concede-then-reverse STRUCTURE. v3 (2026-07-10) replaces v2's treatment
|
|
1812
|
+
# wholesale: drafts converged in shape regardless of assigned style, so v3
|
|
1813
|
+
# makes the style block load-bearing instead of only banning one skeleton.
|
|
1810
1814
|
# The arm is stamped onto every post this cycle via S4L_DRAFT_PROMPT_VARIANT
|
|
1811
1815
|
# (read by twitter_post_plan.py -> log_post.py -> posts.draft_prompt_variant),
|
|
1812
1816
|
# mirroring the tail_link_variant plumbing. Split tunable via
|
|
1813
1817
|
# TWITTER_DRAFT_PROMPT_AB_RATE = fraction of cycles assigned to 'treatment'.
|
|
1814
1818
|
# CODE DEFAULT 0.5 = 50/50 EVERYWHERE (2026-07-06): every install runs a real
|
|
1815
|
-
# holdback so treatment
|
|
1816
|
-
#
|
|
1819
|
+
# holdback so treatment can always be measured against the plain control
|
|
1820
|
+
# prompt. The old default of 1 (100% treatment) was changed because it
|
|
1817
1821
|
# silently dropped the control arm whenever the .env pin did not propagate to the
|
|
1818
1822
|
# running env (the installed-package driver reads its OWN .env, not the source
|
|
1819
1823
|
# tree's), leaving no control data. Robustly defaulting to 0.5 in code, not via an
|
|
1820
1824
|
# .env override, prevents that. The dashboard reads the SAME var with the SAME
|
|
1821
1825
|
# default (bin/server.js), so display and routing never diverge.
|
|
1822
1826
|
DRAFT_PROMPT_AB_RATE="${TWITTER_DRAFT_PROMPT_AB_RATE:-0.5}"
|
|
1823
|
-
# Arm VALUE versioned to '...
|
|
1824
|
-
#
|
|
1825
|
-
#
|
|
1826
|
-
#
|
|
1827
|
-
#
|
|
1827
|
+
# Arm VALUE versioned to '..._v3' on 2026-07-10 to RESET the experiment. The old
|
|
1828
|
+
# v1/v2 rows stay in the DB under their old labels but the dashboard now counts
|
|
1829
|
+
# only the '_v3' arms, so the style-as-form experiment starts fresh from zero.
|
|
1830
|
+
# Bump this suffix again on any future reset (keep bin/server.js
|
|
1831
|
+
# DRAFT_PROMPT_VARIANT_DEFS, scripts/active_experiments.py DESCRIPTIONS, and the
|
|
1832
|
+
# treatment_v3 gate in scripts/engagement_styles.py get_assigned_style_prompt in
|
|
1833
|
+
# sync).
|
|
1828
1834
|
S4L_DRAFT_PROMPT_VARIANT=$(python3 -c "
|
|
1829
1835
|
import random
|
|
1830
1836
|
try:
|
|
@@ -1832,12 +1838,12 @@ try:
|
|
|
1832
1838
|
except Exception:
|
|
1833
1839
|
rate = 0.5
|
|
1834
1840
|
rate = min(1.0, max(0.0, rate))
|
|
1835
|
-
print('
|
|
1836
|
-
" 2>/dev/null || echo
|
|
1841
|
+
print('treatment_v3' if random.random() < rate else 'control_v3')
|
|
1842
|
+
" 2>/dev/null || echo treatment_v3)
|
|
1837
1843
|
export S4L_DRAFT_PROMPT_VARIANT
|
|
1838
1844
|
log "Draft-prompt A/B arm: $S4L_DRAFT_PROMPT_VARIANT (rate=$DRAFT_PROMPT_AB_RATE)"
|
|
1839
|
-
if [ "$S4L_DRAFT_PROMPT_VARIANT" = "
|
|
1840
|
-
DRAFT_DIRECTIVE="Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. If your draft contains that concede-then-reverse pivot, rewrite it from a different entry point. This
|
|
1845
|
+
if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
|
|
1846
|
+
DRAFT_DIRECTIVE="Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. THE ASSIGNED ENGAGEMENT STYLE IS THE FORM OF THIS DRAFT, not a flavor hint: the style block above defines the draft's structure, defining move, and length. Commit to that form BEFORE writing, and run the style block's self-check before returning the draft. Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. If your draft contains that concede-then-reverse pivot, rewrite it from a different entry point. This ban OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's defining move, express it without the skeleton. Lead with substance from ONE entry point and vary the entry point across replies: a concrete first-hand specific or number; a direct answer to the exact question asked; one sharp opinion with no hedge; a genuine question that moves the thread forward; or a relevant pointer. No warm-up framing sentence before the substance. 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. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory, and it works TOGETHER with the engagement style on different layers: the style owns the FORM (structure, defining move, length) and learned_preferences.draft_style_notes own the voice, wording, and content choices INSIDE that form. When a preference seems to conflict with the style, keep the style's structure and satisfy the preference within it; never drop the style's defining move to satisfy a wording note. Treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
1841
1847
|
else
|
|
1842
1848
|
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. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
1843
1849
|
fi
|
|
@@ -1846,20 +1852,28 @@ fi
|
|
|
1846
1852
|
# growth: no product, no link, no CTA. The reply must add real value grounded in
|
|
1847
1853
|
# the persona's first-hand material (the PERSONA CORPUS block + the persona voice
|
|
1848
1854
|
# block), not concede-and-agree filler. Replaces the product-framed promotion
|
|
1849
|
-
# directives above, but is itself arm-aware (see below):
|
|
1850
|
-
# skeleton ban
|
|
1855
|
+
# directives above, but is itself arm-aware (see below): treatment_v3 adds the
|
|
1856
|
+
# skeleton ban + the two-layer style/preferences contract, control_v3 keeps the
|
|
1857
|
+
# plain persona directive, so the A/B runs in this lane too.
|
|
1851
1858
|
if [ "${S4L_ACTIVE_LANE:-}" = "personal_brand" ]; then
|
|
1852
|
-
# Arm-aware
|
|
1853
|
-
# concede-then-reverse ban clause
|
|
1854
|
-
#
|
|
1855
|
-
#
|
|
1856
|
-
#
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
+
# Arm-aware persona lane (v3, 2026-07-10): treatment_v3 adds the
|
|
1860
|
+
# concede-then-reverse ban clause AND swaps the learned_preferences
|
|
1861
|
+
# relation from 'overrides the style on conflict' to the two-layer
|
|
1862
|
+
# contract (style owns form, preferences own voice inside it);
|
|
1863
|
+
# control_v3 keeps the plain persona directive. The style block itself
|
|
1864
|
+
# (STYLES_BLOCK, rendered in this process) is also arm-aware, so the
|
|
1865
|
+
# persona lane gets the style-as-FORM block on treatment automatically.
|
|
1866
|
+
# The arm is stamped onto the card + surfaced (active_experiments.py no
|
|
1867
|
+
# longer drops draft_prompt for personal_brand), so persona cards show
|
|
1868
|
+
# treatment_v3 / control_v3.
|
|
1869
|
+
if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
|
|
1870
|
+
PERSONA_SKELETON_BAN=" Also do NOT use the concede-then-reverse skeleton in ANY form: banned openings include 'X is the easy part/half/win, the hard part is Y', 'X was never the [thing], it's Y', 'X isn't the [problem], it's Y', 'the real/actual/harder part is Y', 'what actually breaks/ships/matters is Y', 'the part nobody says/shows is Y', and 'X is solved, Y is what breaks'; if a draft has that pivot, rewrite it from one of the entry points above. This ban OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's defining move, express it without the skeleton."
|
|
1871
|
+
PERSONA_PREFS_RELATION="(learned_preferences work TOGETHER with the engagement style on different layers: the style owns the FORM, structure, defining move, and length; draft_style_notes own the voice and wording INSIDE that form; on apparent conflict keep the style's structure and satisfy the preference within it)"
|
|
1859
1872
|
else
|
|
1860
1873
|
PERSONA_SKELETON_BAN=""
|
|
1874
|
+
PERSONA_PREFS_RELATION="(it overrides the engagement style's structural template on conflict)"
|
|
1861
1875
|
fi
|
|
1862
|
-
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').${PERSONA_SKELETON_BAN} 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. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing
|
|
1876
|
+
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').${PERSONA_SKELETON_BAN} 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. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing ${PERSONA_PREFS_RELATION}, and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
1863
1877
|
fi
|
|
1864
1878
|
|
|
1865
1879
|
# 2026-07-10 anti-sameness: --no-project-sections strips the multi-project
|
|
@@ -2194,6 +2208,27 @@ CRITICAL:
|
|
|
2194
2208
|
# installs) treats --allowedTools as a one-value flag, so a space-separated second
|
|
2195
2209
|
# tool would leak in as the prompt. On the box these flags ride through
|
|
2196
2210
|
# claude_job.py; Desktop's own web search + the reworded prompt enable it there.
|
|
2211
|
+
# --- Prep-prompt snapshot (2026-07-11) ---------------------------------------
|
|
2212
|
+
# Persist the exact rendered PREP_PROMPT per batch so prompt-block presence is
|
|
2213
|
+
# verifiable after any release (grep the file), instead of reverse-engineering
|
|
2214
|
+
# it from package scripts. The queue's prompt-*.md files are transient work
|
|
2215
|
+
# files deleted on completion, and the generation trace deliberately carries
|
|
2216
|
+
# only the exemplar context, so this is the ONLY durable full-prompt record.
|
|
2217
|
+
# Local-only, newest 50 kept (file cleanup, not candidate-row retention; the
|
|
2218
|
+
# no-retention rule covers DB *_candidates rows). Never blocks the run.
|
|
2219
|
+
PREP_PROMPT_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/prep-prompts"
|
|
2220
|
+
if mkdir -p "$PREP_PROMPT_DIR" 2>/dev/null; then
|
|
2221
|
+
_PP_FILE="$PREP_PROMPT_DIR/prep-prompt-$BATCH_ID.md"
|
|
2222
|
+
if printf '%s' "$PREP_PROMPT" > "$_PP_FILE" 2>/dev/null; then
|
|
2223
|
+
ls -t "$PREP_PROMPT_DIR"/prep-prompt-*.md 2>/dev/null | tail -n +51 | while IFS= read -r _pp_old; do
|
|
2224
|
+
rm -f "$_pp_old"
|
|
2225
|
+
done
|
|
2226
|
+
log "[prep_prompt_snapshot] batch=$BATCH_ID bytes=$(wc -c < "$_PP_FILE" | tr -d ' ') path=$_PP_FILE"
|
|
2227
|
+
else
|
|
2228
|
+
log "WARN: prep-prompt snapshot write failed for batch=$BATCH_ID (non-fatal)"
|
|
2229
|
+
fi
|
|
2230
|
+
fi
|
|
2231
|
+
|
|
2197
2232
|
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)
|
|
2198
2233
|
|
|
2199
2234
|
echo "$PREP_OUTPUT" >> "$LOG_FILE"
|