@m13v/s4l 1.7.2 → 1.7.4-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/dist/index.js +93 -8
- package/mcp/dist/screencast.js +27 -0
- package/mcp/dist/setup.js +8 -2
- package/mcp/dist/twitterAuth.js +3 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +188 -0
- package/mcp/menubar/s4l_card.py +138 -37
- package/mcp/menubar/s4l_log_relay.py +32 -0
- package/mcp/menubar/s4l_menubar.py +20 -7
- package/mcp/menubar/s4l_state.py +37 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/build_persona.py +8 -3
- package/scripts/engagement_styles.py +12 -1
- package/scripts/invent_styles.py +245 -0
- package/scripts/merge_review_queue.py +24 -4
- package/scripts/pick_project.py +12 -3
- package/scripts/recent_self_posts.py +146 -0
- package/scripts/release-mcpb.sh +15 -1
- package/scripts/s4l_mode.py +9 -8
- package/scripts/scan_x_profile.py +143 -11
- package/scripts/tlh_pick_248.py +92 -0
- package/scripts/top_performers.py +55 -9
- package/scripts/twitter_post_plan.py +67 -0
- package/scripts/voice_exemplars.py +424 -0
- package/skill/run-draft-and-publish.sh +4 -3
- package/skill/run-twitter-cycle.sh +24 -4
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""scripts/invent_styles.py — standalone daily engagement-style invention.
|
|
3
|
+
|
|
4
|
+
Architectural split (2026-07-10, mirrors the 2026-05-28 invent_topics.py
|
|
5
|
+
split): in-cycle style invention (the picker's 5% INVENT_RATE roll) is
|
|
6
|
+
retired. It ran INSIDE the drafting prep session, with the top-performers
|
|
7
|
+
leaderboard and winner exemplars in context, so every "new" style was a
|
|
8
|
+
renamed variant of the currently-winning move (agree-then-relocate /
|
|
9
|
+
concede-then-reverse). Combined with name-only dedup in register_style,
|
|
10
|
+
the registry accumulated hundreds of semantic clones.
|
|
11
|
+
|
|
12
|
+
This job runs OUTSIDE any drafting context, on the operator Mac only:
|
|
13
|
+
|
|
14
|
+
- The registry is GLOBAL across installs (every install reads every
|
|
15
|
+
style), so invention must NOT fan out per install the way topic
|
|
16
|
+
invention does. One central daily run is the correct scope; that is
|
|
17
|
+
the deliberate difference from invent_topics.py's per-install kicker.
|
|
18
|
+
- The prompt carries the existing style universe and explicitly forbids
|
|
19
|
+
the dominant structural family; the ask is a style from an
|
|
20
|
+
UNREPRESENTED family, not a riff on the champion.
|
|
21
|
+
- Post-hoc semantic dedup (token-Jaccard on description+example, plus a
|
|
22
|
+
reframe-family heuristic) rejects near-clones; a rejection re-prompts
|
|
23
|
+
with a grown avoid-list, at most DUPE_RETRIES times.
|
|
24
|
+
- Accepted styles are registered via engagement_styles.register_style
|
|
25
|
+
(kind='model_invented'), same as the retired inline path, so pickers
|
|
26
|
+
see them on their next tick with zero other wiring.
|
|
27
|
+
|
|
28
|
+
Scheduling: launchd com.m13v.s4l-invent-styles (operator Mac, daily).
|
|
29
|
+
Uses run_claude.sh with tag 'invent-styles'; the tag is NOT in
|
|
30
|
+
claude_job.py TAG_TO_TYPE, so it runs the local `claude -p` lane and has
|
|
31
|
+
no dependency on the Desktop queue worker.
|
|
32
|
+
|
|
33
|
+
CLI:
|
|
34
|
+
python3 scripts/invent_styles.py # invent + register 1
|
|
35
|
+
python3 scripts/invent_styles.py --max-new 2
|
|
36
|
+
python3 scripts/invent_styles.py --dry-run # propose, don't register
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import subprocess
|
|
44
|
+
import sys
|
|
45
|
+
|
|
46
|
+
_REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
47
|
+
sys.path.insert(0, os.path.join(_REPO_DIR, "scripts"))
|
|
48
|
+
from engagement_styles import get_all_styles, register_style # noqa: E402
|
|
49
|
+
|
|
50
|
+
_RUN_CLAUDE_SH = os.path.join(_REPO_DIR, "scripts", "run_claude.sh")
|
|
51
|
+
SCRIPT_TAG = "invent-styles"
|
|
52
|
+
CALL_TIMEOUT_SEC = 420
|
|
53
|
+
DUPE_RETRIES = 3
|
|
54
|
+
SIMILARITY_THRESHOLD = 0.5 # Jaccard on description+example tokens
|
|
55
|
+
|
|
56
|
+
# Heuristic markers of the saturated agree-then-relocate/reframe family.
|
|
57
|
+
# A proposal whose description/example leans on these is a clone of the
|
|
58
|
+
# dominant move no matter how novel its name is; reject like a dupe.
|
|
59
|
+
_REFRAME_MARKERS = re.compile(
|
|
60
|
+
r"\b(reframe|relocat\w+|the real (work|question|problem|cost|part|win)"
|
|
61
|
+
r"|hidden (cost|part|work|meter)|easy (part|half)|hard(er)? (part|half)"
|
|
62
|
+
r"|was never (the|about)|nobody (mentions|talks about|tracks|measures)"
|
|
63
|
+
r"|isn.t the .{0,30}it.s|concede)\b",
|
|
64
|
+
re.I,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tokens(text):
|
|
69
|
+
return set(re.findall(r"[a-z0-9]+", (text or "").lower()))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _jaccard(a, b):
|
|
73
|
+
ta, tb = _tokens(a), _tokens(b)
|
|
74
|
+
if not ta or not tb:
|
|
75
|
+
return 0.0
|
|
76
|
+
return len(ta & tb) / len(ta | tb)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _style_text(entry):
|
|
80
|
+
return f"{entry.get('description') or ''} {entry.get('example') or ''}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _find_near_dupe(proposal, universe):
|
|
84
|
+
"""Return (reason, existing_name) when the proposal is a clone; else None."""
|
|
85
|
+
name = proposal["name"]
|
|
86
|
+
if name in universe:
|
|
87
|
+
return ("name_exists", name)
|
|
88
|
+
ptext = _style_text(proposal)
|
|
89
|
+
if _REFRAME_MARKERS.search(f"{proposal.get('description','')} {name}"):
|
|
90
|
+
return ("reframe_family", "(dominant agree-then-relocate family)")
|
|
91
|
+
best_name, best_sim = None, 0.0
|
|
92
|
+
for ename, entry in universe.items():
|
|
93
|
+
sim = _jaccard(ptext, _style_text(entry))
|
|
94
|
+
if sim > best_sim:
|
|
95
|
+
best_name, best_sim = ename, sim
|
|
96
|
+
if best_sim >= SIMILARITY_THRESHOLD:
|
|
97
|
+
return (f"jaccard={best_sim:.2f}", best_name)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_prompt(universe, avoid):
|
|
102
|
+
names = sorted(universe.keys())
|
|
103
|
+
# Detail only a bounded sample (prompt-size guard): the seeds plus the
|
|
104
|
+
# most recently registered rows carry enough signal about what already
|
|
105
|
+
# exists; the full NAME list covers the rest for the model's self-check.
|
|
106
|
+
detailed = []
|
|
107
|
+
for name in names:
|
|
108
|
+
e = universe[name]
|
|
109
|
+
if e.get("kind") == "seed" or len(detailed) < 40:
|
|
110
|
+
desc = " ".join((e.get("description") or "").split())[:180]
|
|
111
|
+
detailed.append(f"- {name}: {desc}")
|
|
112
|
+
if len(detailed) >= 60:
|
|
113
|
+
break
|
|
114
|
+
avoid_block = ""
|
|
115
|
+
if avoid:
|
|
116
|
+
avoid_block = (
|
|
117
|
+
"\nAlready proposed and REJECTED this run (do not resubmit or "
|
|
118
|
+
"paraphrase): " + ", ".join(avoid) + "\n"
|
|
119
|
+
)
|
|
120
|
+
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
|
+
|
|
122
|
+
PROBLEM: 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 invent another member of that family, however disguised.
|
|
123
|
+
|
|
124
|
+
EXISTING STYLE NAMES ({len(names)} total):
|
|
125
|
+
{", ".join(names)}
|
|
126
|
+
|
|
127
|
+
REPRESENTATIVE DETAILS (sample):
|
|
128
|
+
{chr(10).join(detailed)}
|
|
129
|
+
{avoid_block}
|
|
130
|
+
TASK: invent exactly ONE genuinely new style from a structural family that is missing or rare above. Families worth mining (pick ONE, or another 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.
|
|
131
|
+
|
|
132
|
+
Rules:
|
|
133
|
+
- The example must read like a real human reply (lowercase ok), 40-220 chars, NO links, NO product names.
|
|
134
|
+
- The style must be usable across many products and threads, not thread-specific.
|
|
135
|
+
- Your answer will be dedup-checked by token similarity against every existing style's description+example; if you cannot find a genuinely different move, return the saturation envelope instead of forcing a paraphrase.
|
|
136
|
+
|
|
137
|
+
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, the length the example is>}}
|
|
139
|
+
{{"saturated": true, "reason": "..."}}"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def call_claude(prompt):
|
|
143
|
+
proc = subprocess.run(
|
|
144
|
+
["bash", _RUN_CLAUDE_SH, SCRIPT_TAG, "-p", "--output-format", "json",
|
|
145
|
+
prompt],
|
|
146
|
+
text=True, capture_output=True, timeout=CALL_TIMEOUT_SEC,
|
|
147
|
+
)
|
|
148
|
+
if proc.returncode == 79:
|
|
149
|
+
raise SystemExit("[invent_styles] provider blocked (exit 79); skipping run")
|
|
150
|
+
if proc.returncode != 0:
|
|
151
|
+
raise SystemExit(
|
|
152
|
+
f"[invent_styles] run_claude.sh exited {proc.returncode}: "
|
|
153
|
+
f"{(proc.stderr or '')[:400]}")
|
|
154
|
+
try:
|
|
155
|
+
envelope = json.loads(proc.stdout)
|
|
156
|
+
text = envelope.get("result") or ""
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
text = proc.stdout
|
|
159
|
+
return text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _extract_json(text):
|
|
163
|
+
"""Pull the first parseable JSON object out of model output."""
|
|
164
|
+
text = (text or "").strip()
|
|
165
|
+
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
|
|
166
|
+
if fence:
|
|
167
|
+
text = fence.group(1)
|
|
168
|
+
start = text.find("{")
|
|
169
|
+
while start != -1:
|
|
170
|
+
for end in range(len(text), start, -1):
|
|
171
|
+
try:
|
|
172
|
+
obj = json.loads(text[start:end])
|
|
173
|
+
if isinstance(obj, dict):
|
|
174
|
+
return obj
|
|
175
|
+
except json.JSONDecodeError:
|
|
176
|
+
continue
|
|
177
|
+
start = text.find("{", start + 1)
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main():
|
|
182
|
+
parser = argparse.ArgumentParser(description="Standalone style invention job")
|
|
183
|
+
parser.add_argument("--max-new", type=int, default=1,
|
|
184
|
+
help="How many styles to invent this run (default 1)")
|
|
185
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
186
|
+
help="Propose and dedup-check but do not register")
|
|
187
|
+
args = parser.parse_args()
|
|
188
|
+
|
|
189
|
+
registered = 0
|
|
190
|
+
for slot in range(max(1, args.max_new)):
|
|
191
|
+
universe = get_all_styles()
|
|
192
|
+
avoid = []
|
|
193
|
+
accepted = None
|
|
194
|
+
for attempt in range(1 + DUPE_RETRIES):
|
|
195
|
+
raw = call_claude(build_prompt(universe, avoid))
|
|
196
|
+
obj = _extract_json(raw)
|
|
197
|
+
if not obj:
|
|
198
|
+
print(f"[invent_styles] slot={slot} attempt={attempt} "
|
|
199
|
+
f"unparseable output: {raw[:200]!r}", file=sys.stderr)
|
|
200
|
+
continue
|
|
201
|
+
if obj.get("saturated"):
|
|
202
|
+
print(f"[invent_styles] slot={slot} model reports saturation: "
|
|
203
|
+
f"{obj.get('reason', '')[:200]}", file=sys.stderr)
|
|
204
|
+
break
|
|
205
|
+
name = str(obj.get("name") or "").strip()
|
|
206
|
+
if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
|
|
207
|
+
print(f"[invent_styles] slot={slot} bad name {name!r}; retrying",
|
|
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)
|
|
217
|
+
continue
|
|
218
|
+
accepted = {**obj, "name": name}
|
|
219
|
+
break
|
|
220
|
+
if not accepted:
|
|
221
|
+
continue
|
|
222
|
+
if args.dry_run:
|
|
223
|
+
print(json.dumps({"dry_run": True, **accepted}, indent=2))
|
|
224
|
+
continue
|
|
225
|
+
status, entry = register_style(
|
|
226
|
+
accepted["name"],
|
|
227
|
+
{
|
|
228
|
+
"description": accepted.get("description", ""),
|
|
229
|
+
"example": accepted.get("example", ""),
|
|
230
|
+
"why_existing_didnt_fit": accepted.get("why_existing_didnt_fit", ""),
|
|
231
|
+
"target_chars": accepted.get("target_chars"),
|
|
232
|
+
},
|
|
233
|
+
source_post={"platform": "invent_styles_job", "model": "invent_styles"},
|
|
234
|
+
)
|
|
235
|
+
print(f"[invent_styles] slot={slot} register status={status} "
|
|
236
|
+
f"name={accepted['name']}", file=sys.stderr)
|
|
237
|
+
if status == "new":
|
|
238
|
+
registered += 1
|
|
239
|
+
print(json.dumps({"registered": accepted["name"],
|
|
240
|
+
"description": accepted.get("description", "")}))
|
|
241
|
+
print(f"[invent_styles] done: registered={registered}", file=sys.stderr)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
main()
|
|
@@ -19,6 +19,15 @@ Usage:
|
|
|
19
19
|
|
|
20
20
|
State dir (for review-request.json) honors $S4L_STATE_DIR; the review-queue plan
|
|
21
21
|
lives in $S4L_TMP_DIR or /tmp (matching the MCP's planPath()).
|
|
22
|
+
|
|
23
|
+
LEDGER SEMANTICS (read this before counting anything in review-queue.json):
|
|
24
|
+
the queue is APPEND-FOREVER — handled candidates are never removed, they are
|
|
25
|
+
flag-stamped in place (posted / terminal+terminal_reason / post_failed /
|
|
26
|
+
approved). "Not posted" is therefore NOT "awaiting review"; in an old queue
|
|
27
|
+
most rows are retired. The canonical classifier is
|
|
28
|
+
mcp/menubar/s4l_state.py::candidate_state(); the honest pending-cards count is
|
|
29
|
+
its awaiting_review bucket, mirrored into review-request.json's .count here at
|
|
30
|
+
merge time.
|
|
22
31
|
"""
|
|
23
32
|
|
|
24
33
|
from __future__ import annotations
|
|
@@ -318,10 +327,21 @@ def main() -> int:
|
|
|
318
327
|
_atomic_write(dst, plan_obj)
|
|
319
328
|
ensure_store_symlink()
|
|
320
329
|
|
|
321
|
-
# Refresh the review-request marker the menu bar polls
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
|
|
330
|
+
# Refresh the review-request marker the menu bar polls. count = cards
|
|
331
|
+
# actually awaiting review (mirrors s4l_state.candidate_state()'s
|
|
332
|
+
# awaiting_review bucket): approved-unposted and post_failed rows are
|
|
333
|
+
# settled decisions, counting them inflated the badge and misled every
|
|
334
|
+
# human/agent reading the marker.
|
|
335
|
+
pending_count = len(
|
|
336
|
+
[
|
|
337
|
+
c
|
|
338
|
+
for c in merged
|
|
339
|
+
if not c.get("posted")
|
|
340
|
+
and not c.get("terminal")
|
|
341
|
+
and not c.get("post_failed")
|
|
342
|
+
and not c.get("approved")
|
|
343
|
+
]
|
|
344
|
+
)
|
|
325
345
|
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
326
346
|
_atomic_write(
|
|
327
347
|
review_request_path(),
|
package/scripts/pick_project.py
CHANGED
|
@@ -74,10 +74,17 @@ def recent_posts_by_project(platform=None, days=RECENT_WINDOW_DAYS):
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def _eligible_pool(config, platform=None, exclude=None):
|
|
77
|
-
"""Projects eligible for selection: enabled, weight>0,
|
|
77
|
+
"""Projects eligible for selection: enabled, weight>0, non-persona,
|
|
78
|
+
platform-compatible.
|
|
79
|
+
|
|
80
|
+
Persona entries (`persona: true`) are structurally excluded: they run ONLY
|
|
81
|
+
via the personal_brand lane's force-inject (s4l_mode.persona_name), never
|
|
82
|
+
via this promotion lottery, regardless of their `enabled` flag.
|
|
83
|
+
"""
|
|
78
84
|
pool = [
|
|
79
85
|
p for p in config.get("projects", [])
|
|
80
86
|
if p.get("enabled", True) and p.get("weight", 0) > 0
|
|
87
|
+
and p.get("persona") is not True
|
|
81
88
|
]
|
|
82
89
|
if exclude:
|
|
83
90
|
excluded = {n.lower() for n in exclude}
|
|
@@ -122,8 +129,10 @@ def pick_project(config, platform=None, exclude=None):
|
|
|
122
129
|
return picks[0]
|
|
123
130
|
if exclude:
|
|
124
131
|
return None
|
|
125
|
-
# No eligible project at all: legacy fallback to any project
|
|
126
|
-
projects =
|
|
132
|
+
# No eligible project at all: legacy fallback to any non-persona project.
|
|
133
|
+
projects = [
|
|
134
|
+
p for p in config.get("projects", []) if p.get("persona") is not True
|
|
135
|
+
]
|
|
127
136
|
return random.choice(projects) if projects else None
|
|
128
137
|
|
|
129
138
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""scripts/recent_self_posts.py — cross-cycle anti-repetition context.
|
|
3
|
+
|
|
4
|
+
Prints a prompt block listing OUR most recent posted replies on a platform
|
|
5
|
+
(across ALL projects), so a drafting session can see what this account
|
|
6
|
+
already sounds like and deliberately diverge. This is the cross-cycle
|
|
7
|
+
complement to author_history_block.py (which is per-author only): before
|
|
8
|
+
2026-07-10 the model never saw its own recent output across threads and
|
|
9
|
+
kept recycling the same openers and sentence skeletons cycle after cycle.
|
|
10
|
+
|
|
11
|
+
The block is explicitly NEGATIVE context ("do not sound like these"), the
|
|
12
|
+
opposite of the top_performers few-shots. Keep it that way: never add
|
|
13
|
+
engagement numbers or any "this one did well" framing here, or the model
|
|
14
|
+
will read it as examples to imitate.
|
|
15
|
+
|
|
16
|
+
Wired into (one callsite):
|
|
17
|
+
- skill/run-twitter-cycle.sh (Phase 2b-prep PREP_PROMPT)
|
|
18
|
+
|
|
19
|
+
CLI:
|
|
20
|
+
python3 scripts/recent_self_posts.py --platform twitter --limit 20
|
|
21
|
+
|
|
22
|
+
Stdout is a ready-to-inject prompt block; EMPTY stdout when there are no
|
|
23
|
+
rows or on any failure (the cycle must never block on this context).
|
|
24
|
+
Stderr carries diagnostics only.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
32
|
+
sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
|
|
33
|
+
from http_api import api_get # noqa: E402
|
|
34
|
+
|
|
35
|
+
# Truncation length per reply. Long enough to expose the opener + skeleton
|
|
36
|
+
# (the parts the model must avoid repeating), short enough that 20 rows stay
|
|
37
|
+
# a small fraction of the prompt.
|
|
38
|
+
SNIPPET_CHARS = 220
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_active_campaign_suffixes():
|
|
42
|
+
"""Best-effort list of active campaign suffix literals to strip.
|
|
43
|
+
|
|
44
|
+
Same contract as author_history_block._load_active_campaign_suffixes:
|
|
45
|
+
the block must never teach the model to echo a campaign suffix (the
|
|
46
|
+
tool layer appends its own copy at post time). On failure returns [].
|
|
47
|
+
"""
|
|
48
|
+
suffixes = []
|
|
49
|
+
try:
|
|
50
|
+
resp = api_get(
|
|
51
|
+
"/api/v1/campaigns",
|
|
52
|
+
query={"status": "active", "has_suffix": "true", "limit": 500},
|
|
53
|
+
)
|
|
54
|
+
rows = ((resp or {}).get("data") or {}).get("campaigns") or []
|
|
55
|
+
for r in rows:
|
|
56
|
+
s = (r.get("suffix") or "").strip()
|
|
57
|
+
if s and s not in suffixes:
|
|
58
|
+
suffixes.append(s)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"[recent_self_posts] suffix load failed: {e!r}", file=sys.stderr)
|
|
61
|
+
return suffixes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _strip_suffixes(text, suffixes):
|
|
65
|
+
"""Trailing-only, idempotent strip (mirrors author_history_block)."""
|
|
66
|
+
if not text or not suffixes:
|
|
67
|
+
return text
|
|
68
|
+
cleaned = text.rstrip()
|
|
69
|
+
changed = True
|
|
70
|
+
while changed:
|
|
71
|
+
changed = False
|
|
72
|
+
for sfx in suffixes:
|
|
73
|
+
if sfx and cleaned.endswith(sfx):
|
|
74
|
+
cleaned = cleaned[: -len(sfx)].rstrip()
|
|
75
|
+
changed = True
|
|
76
|
+
return cleaned
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _snippet(text, suffixes):
|
|
80
|
+
"""One-line, suffix-stripped, truncated rendering of a reply."""
|
|
81
|
+
t = _strip_suffixes((text or "").strip(), suffixes)
|
|
82
|
+
t = " ".join(t.split()) # collapse newlines/runs of whitespace
|
|
83
|
+
if len(t) > SNIPPET_CHARS:
|
|
84
|
+
t = t[: SNIPPET_CHARS - 1].rstrip() + "…"
|
|
85
|
+
return t
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_block(platform, limit):
|
|
89
|
+
"""Return the prompt block string, or "" when nothing to show."""
|
|
90
|
+
resp = api_get(
|
|
91
|
+
"/api/v1/posts",
|
|
92
|
+
query={
|
|
93
|
+
"platform": platform,
|
|
94
|
+
"status": "active",
|
|
95
|
+
"order_by": "posted_at",
|
|
96
|
+
"order_dir": "desc",
|
|
97
|
+
"limit": str(limit),
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
101
|
+
suffixes = _load_active_campaign_suffixes()
|
|
102
|
+
items = []
|
|
103
|
+
for r in rows:
|
|
104
|
+
snip = _snippet(r.get("our_content"), suffixes)
|
|
105
|
+
if not snip:
|
|
106
|
+
continue
|
|
107
|
+
date = str(r.get("posted_at") or "")[:10]
|
|
108
|
+
proj = r.get("project_name") or "(no project)"
|
|
109
|
+
items.append(f"{len(items) + 1}. [{date} | {proj}] {snip}")
|
|
110
|
+
if len(items) >= limit:
|
|
111
|
+
break
|
|
112
|
+
if not items:
|
|
113
|
+
return ""
|
|
114
|
+
header = (
|
|
115
|
+
"## YOUR RECENT REPLIES (cross-cycle anti-repetition; NEGATIVE examples)\n"
|
|
116
|
+
f"The {len(items)} most recent replies this account posted, all projects. "
|
|
117
|
+
"This is what you ALREADY sound like. It is NOT a list to imitate. "
|
|
118
|
+
"Hard rules for every draft this cycle:\n"
|
|
119
|
+
"- Do NOT reuse any opener below (the first 6-8 words' shape counts, "
|
|
120
|
+
"not just the exact words).\n"
|
|
121
|
+
"- Do NOT reuse their sentence skeletons, rhetorical moves, or pet "
|
|
122
|
+
"phrases (recurring words like 'actually', 'the real X', copula "
|
|
123
|
+
"reframes 'X is the Y').\n"
|
|
124
|
+
"- If a draft you are writing starts to echo any entry below, stop "
|
|
125
|
+
"and rewrite it from a different entry point.\n"
|
|
126
|
+
)
|
|
127
|
+
return header + "\n".join(items)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main():
|
|
131
|
+
parser = argparse.ArgumentParser(
|
|
132
|
+
description="Render the cross-cycle recent-self-replies prompt block")
|
|
133
|
+
parser.add_argument("--platform", default="twitter")
|
|
134
|
+
parser.add_argument("--limit", type=int, default=20)
|
|
135
|
+
args = parser.parse_args()
|
|
136
|
+
try:
|
|
137
|
+
block = build_block(args.platform, max(1, min(args.limit, 50)))
|
|
138
|
+
except Exception as e:
|
|
139
|
+
print(f"[recent_self_posts] failed: {e!r}", file=sys.stderr)
|
|
140
|
+
block = ""
|
|
141
|
+
if block:
|
|
142
|
+
print(block)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -460,7 +460,21 @@ if [[ "$DO_NPM" == "1" ]]; then
|
|
|
460
460
|
fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
|
|
461
461
|
console.log(' temp copy renamed to $S4L_ALIAS_PKG@'+j.version+' (repo package.json untouched)');
|
|
462
462
|
" \
|
|
463
|
-
&& (
|
|
463
|
+
&& (
|
|
464
|
+
# The registry's large-upload path flakes (TLS bad record mac / EPIPE)
|
|
465
|
+
# often enough that one attempt let the alias lane silently drift for
|
|
466
|
+
# three rc's (rc.16/21/22, caught 2026-07-10). Retry, and between
|
|
467
|
+
# attempts check whether the upload actually landed despite the
|
|
468
|
+
# client-side error (EPIPE can be a false negative).
|
|
469
|
+
cd "$S4L_PUB_DIR/package" || exit 1
|
|
470
|
+
for _try in 1 2 3; do
|
|
471
|
+
npm publish --access=public ${NPM_TAG_ARGS[@]+"${NPM_TAG_ARGS[@]}"} && exit 0
|
|
472
|
+
sleep 3
|
|
473
|
+
[[ "$(curl -s -o /dev/null -w '%{http_code}' "$S4L_ALIAS_URL")" == "200" ]] && exit 0
|
|
474
|
+
echo " dual-publish attempt $_try/3 failed" >&2
|
|
475
|
+
done
|
|
476
|
+
exit 1
|
|
477
|
+
); then
|
|
464
478
|
for _ in 1 2 3 4 5; do
|
|
465
479
|
sleep 2
|
|
466
480
|
[[ "$(curl -s -o /dev/null -w '%{http_code}' "$S4L_ALIAS_URL")" == "200" ]] && break
|
package/scripts/s4l_mode.py
CHANGED
|
@@ -58,14 +58,15 @@ Usage:
|
|
|
58
58
|
s4l_mode.py split # print the personal-brand share (0.0-1.0)
|
|
59
59
|
s4l_mode.py split <value> # set it; accepts 70, 70%, or 0.7
|
|
60
60
|
|
|
61
|
-
Draft-only (2026-07-06
|
|
62
|
-
While ON (the normal state), every
|
|
63
|
-
become review cards. When an
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
Draft-only (2026-07-06, single global switch as of 2026-07-08): a third,
|
|
62
|
+
independent flag in mode.json, DEFAULT ON. While ON (the normal state), every
|
|
63
|
+
cycle stops before posting and its drafts become review cards. When an
|
|
64
|
+
OPERATOR turns it off, run-draft-and-publish.sh runs EVERY lane (promotion AND
|
|
65
|
+
personal_brand) with DRAFT_ONLY=0 so they POST autonomously (promotion
|
|
66
|
+
additionally runs behind the rolling virality bar). This applies uniformly
|
|
67
|
+
across lanes; persona-lane cycles do NOT stay draft-only when the flag is off.
|
|
68
|
+
Deliberately NOT exposed on any user surface (no MCP tool param, no menubar
|
|
69
|
+
toggle): this CLI and mode.json are the only way to flip it.
|
|
69
70
|
`env` additionally exports S4L_CYCLE_LANE=<lane> for both lanes so the wrapper
|
|
70
71
|
knows which lane this cycle is without re-deriving it.
|
|
71
72
|
|