@m13v/s4l 1.7.1-rc.2 → 1.7.1-rc.21
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/bin/cli.js +23 -0
- package/mcp/dist/index.js +332 -24
- package/mcp/dist/onboarding.js +3 -10
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/runtime.js +101 -1
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +472 -96
- package/mcp/menubar/s4l_menubar.py +269 -26
- package/mcp/menubar/s4l_state.py +169 -23
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +8 -0
- package/mcp/shared/onboarding-ledger.cjs +1 -0
- package/package.json +3 -1
- package/scripts/active_users.py +3 -6
- package/scripts/autopilot_stall_watch.py +200 -16
- package/scripts/claude_job.py +6 -0
- package/scripts/feedback_digest.py +34 -6
- package/scripts/get-latest-staging-mcpb.sh +50 -0
- package/scripts/harness_overlay.py +45 -0
- package/scripts/identity.py +26 -0
- package/scripts/learned_preferences.py +241 -32
- package/scripts/link_tail.py +167 -91
- package/scripts/mark_event.py +128 -0
- package/scripts/memory_snapshot.py +62 -0
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/release-mcpb.sh +48 -0
- package/scripts/reset-test-machine.sh +85 -3
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scheduled_tasks_snapshot.py +107 -0
- package/scripts/send_gmail_report.py +76 -0
- package/scripts/sentry_digest.py +81 -189
- package/scripts/sentry_init.py +43 -15
- package/scripts/setup_twitter_auth.py +76 -38
- package/scripts/snapshot.py +20 -0
- package/scripts/twitter_post_plan.py +39 -13
- package/skill/dm-outreach-twitter.sh +19 -0
- package/skill/engage-twitter.sh +27 -0
- package/skill/run-draft-and-publish.sh +13 -12
- package/skill/run-linkedin.sh +9 -1
- package/skill/run-twitter-cycle.sh +199 -27
- package/skill/sentry-digest.sh +125 -12
package/scripts/link_tail.py
CHANGED
|
@@ -13,6 +13,15 @@ Haiku) that:
|
|
|
13
13
|
4. Writes 1 short bridge sentence that names a concrete benefit and
|
|
14
14
|
ends with the URL — no period after, no "click here".
|
|
15
15
|
|
|
16
|
+
APPEND-ONLY, deterministically (2026-07-07): the model is asked for the
|
|
17
|
+
bridge sentence ONLY, never the full reply. main() concatenates it onto the
|
|
18
|
+
UNMODIFIED reply_text in Python, so the model's output is never substituted
|
|
19
|
+
for the drafted reply, only appended after it — it cannot rewrite the
|
|
20
|
+
drafted body no matter what it returns. The bridge prompt also embeds the
|
|
21
|
+
matched project's learned_preferences (draft_style_notes / edit_examples,
|
|
22
|
+
same as the main drafting prompt) so human-review feedback reaches the
|
|
23
|
+
bridge sentence too, not just the initial draft.
|
|
24
|
+
|
|
16
25
|
Why not Haiku: bridge writing requires reading two pieces of context (thread
|
|
17
26
|
+ our reply) and producing language that doesn't read as bolted-on. The
|
|
18
27
|
cheap model fails this; tested via the existing studyly Twitter dataset
|
|
@@ -54,6 +63,9 @@ import sys
|
|
|
54
63
|
import time
|
|
55
64
|
from pathlib import Path
|
|
56
65
|
|
|
66
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
67
|
+
import learned_preferences # noqa: E402
|
|
68
|
+
|
|
57
69
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
58
70
|
RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
|
|
59
71
|
CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
@@ -68,6 +80,16 @@ URL_WEIGHT = 23
|
|
|
68
80
|
TWITTER_TEXT_BUDGET = TWEET_LIMIT - URL_WEIGHT # 257 chars for everything but the URL
|
|
69
81
|
_URL_RE = re.compile(r"https?://\S+")
|
|
70
82
|
|
|
83
|
+
# The model is asked for the BRIDGE SENTENCE ONLY, never a rewrite of the
|
|
84
|
+
# already-drafted reply (see build_prompt / main). These bound that sentence:
|
|
85
|
+
# below MIN_BRIDGE_CHARS of budget there's no room to say anything meaningful
|
|
86
|
+
# before the URL, so main() skips the Claude call and falls straight to a
|
|
87
|
+
# mechanical concat; above MAX_BRIDGE_CHARS the model has ignored the
|
|
88
|
+
# "sentence only" contract and is attempting a full rewrite, so the quality
|
|
89
|
+
# gate rejects it as a structural violation, not a phrasing nitpick.
|
|
90
|
+
MIN_BRIDGE_CHARS = 20
|
|
91
|
+
MAX_BRIDGE_CHARS = 400
|
|
92
|
+
|
|
71
93
|
|
|
72
94
|
def x_weighted_len(text: str) -> int:
|
|
73
95
|
"""Character count the way X computes it: every URL counts as 23."""
|
|
@@ -115,28 +137,42 @@ def enforce_budget(text: str, link_url: str,
|
|
|
115
137
|
return _trim_to_chars(text, limit), True
|
|
116
138
|
|
|
117
139
|
|
|
118
|
-
def
|
|
119
|
-
"""
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
so the value must be resolved here in Python rather than inside the prompt.
|
|
140
|
+
def _load_project_entry(project: str) -> dict:
|
|
141
|
+
"""Load the matched project's raw config.json entry (case-insensitive
|
|
142
|
+
name match), or {} if config.json is unreadable or the project is
|
|
143
|
+
missing. Shared by voice_relationship + learned_preferences lookups so
|
|
144
|
+
both read config.json once via the same path. The link_tail subprocess
|
|
145
|
+
runs with --disallowed-tools that bans Read/Glob/MCP, so these values
|
|
146
|
+
must be resolved here in Python and handed to the prompt as plain text.
|
|
126
147
|
"""
|
|
127
148
|
try:
|
|
128
149
|
with open(CONFIG_PATH, "r") as f:
|
|
129
150
|
cfg = json.load(f)
|
|
130
151
|
except Exception:
|
|
131
|
-
return
|
|
152
|
+
return {}
|
|
132
153
|
name_lc = (project or "").lower()
|
|
133
154
|
for p in cfg.get("projects", []):
|
|
134
155
|
if (p.get("name") or "").lower() == name_lc:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
return p
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_voice_relationship(project_entry: dict) -> str:
|
|
161
|
+
"""Returns "first_party" or "third_party" from the project's
|
|
162
|
+
`voice_relationship` field. Defaults to "first_party" if the field is
|
|
163
|
+
absent, matching the historical pre-2026-05-27 behavior so we never
|
|
164
|
+
silently mute first-party voice."""
|
|
165
|
+
val = (project_entry.get("voice_relationship") or "").strip().lower()
|
|
166
|
+
return val if val in ("first_party", "third_party") else "first_party"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_learned_preferences_block(project_entry: dict) -> str:
|
|
170
|
+
"""Render this project's learned_preferences (draft_style_notes,
|
|
171
|
+
edit_examples, audience/thread avoid-prefer) via the shared
|
|
172
|
+
learned_preferences.prompt_block() renderer — the same text the main
|
|
173
|
+
drafting prompt embeds. Empty string when the block is disabled or has
|
|
174
|
+
no entries yet."""
|
|
175
|
+
return learned_preferences.prompt_block(project_entry)
|
|
140
176
|
|
|
141
177
|
# Paths to the Claude Code CLI in order of preference. run_claude.sh resolves
|
|
142
178
|
# `claude` from PATH; we fall back to a direct nvm path if PATH lookup fails
|
|
@@ -169,17 +205,28 @@ def resolve_claude_cli() -> str:
|
|
|
169
205
|
|
|
170
206
|
def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
|
|
171
207
|
project: str, platform: str,
|
|
172
|
-
voice_relationship: str = "first_party"
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
208
|
+
voice_relationship: str = "first_party",
|
|
209
|
+
learned_prefs_block: str = "",
|
|
210
|
+
bridge_budget: int | None = None) -> str:
|
|
211
|
+
"""Compose the one-shot prompt for the bridge sentence ONLY.
|
|
212
|
+
|
|
213
|
+
The model is never asked to reproduce or rewrite the reply already
|
|
214
|
+
drafted; it's shown that text for context and told explicitly not to
|
|
215
|
+
repeat it. main() appends whatever comes back to the UNMODIFIED
|
|
216
|
+
reply_text in Python afterward — the model's output is never substituted
|
|
217
|
+
for the drafted reply, only concatenated after it, so it structurally
|
|
218
|
+
cannot rewrite the drafted body regardless of what it outputs.
|
|
177
219
|
|
|
178
220
|
`voice_relationship` ("first_party" | "third_party") is resolved by the
|
|
179
221
|
caller from config.json and selects the example sentences + voice rule
|
|
180
222
|
embedded in the prompt. third_party projects (Agora, Runner, Podlog,
|
|
181
223
|
studyly, NightOwl, PieLine as of 2026-05-27) MUST be referred to in
|
|
182
224
|
third-person; first_party projects own the "we ship / we built" voice.
|
|
225
|
+
|
|
226
|
+
`learned_prefs_block` is the project's rendered learned_preferences
|
|
227
|
+
(learned_preferences.prompt_block()), the same distilled human-review
|
|
228
|
+
feedback the main drafting prompt treats as mandatory; empty when the
|
|
229
|
+
project has no notes yet.
|
|
183
230
|
"""
|
|
184
231
|
if voice_relationship == "third_party":
|
|
185
232
|
voice_rule = (
|
|
@@ -209,27 +256,35 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
|
|
|
209
256
|
f" - \"we ship the same recall-on-revisit pattern in {project}, scores against a 4-axis rubric, {link_url}\""
|
|
210
257
|
)
|
|
211
258
|
|
|
212
|
-
# X-specific hard length budget.
|
|
213
|
-
# the
|
|
214
|
-
# the
|
|
215
|
-
#
|
|
259
|
+
# X-specific hard length budget. The reply's body is FIXED (never resized
|
|
260
|
+
# by the model); only the bridge sentence's own budget matters, computed
|
|
261
|
+
# by the caller from how much room the already-drafted body leaves.
|
|
262
|
+
# Other platforms (reddit/linkedin) have far larger ceilings, so we add
|
|
263
|
+
# no tight cap there.
|
|
216
264
|
length_rule = ""
|
|
217
|
-
if platform == "twitter":
|
|
218
|
-
body_len = len(reply_text or "")
|
|
265
|
+
if platform == "twitter" and bridge_budget is not None:
|
|
219
266
|
length_rule = (
|
|
220
|
-
f"HARD LENGTH LIMIT (X counts EVERY link as
|
|
221
|
-
f"no matter how long it looks
|
|
222
|
-
f"
|
|
223
|
-
f"
|
|
224
|
-
f"
|
|
225
|
-
f"
|
|
226
|
-
f"bridge would exceed {TWITTER_TEXT_BUDGET} chars of text, COMPRESS "
|
|
227
|
-
f"the body to make room: tighten wording, drop the weakest clause, "
|
|
228
|
-
f"but keep the single strongest claim and keep the URL at the very "
|
|
229
|
-
f"end. Never drop or move the link.\n"
|
|
267
|
+
f"HARD LENGTH LIMIT for YOUR SENTENCE ONLY (X counts EVERY link as "
|
|
268
|
+
f"exactly {URL_WEIGHT} characters, no matter how long it looks; "
|
|
269
|
+
f"that's already reserved below):\n"
|
|
270
|
+
f"- Everything you write BEFORE the URL must be \u2264 {max(bridge_budget, 0)} "
|
|
271
|
+
f"characters. The reply above is fixed length; you are not resizing "
|
|
272
|
+
f"it, only adding a short sentence after it.\n"
|
|
230
273
|
)
|
|
231
274
|
|
|
232
|
-
|
|
275
|
+
learned_prefs_section = ""
|
|
276
|
+
if learned_prefs_block:
|
|
277
|
+
learned_prefs_section = (
|
|
278
|
+
learned_prefs_block.strip() + "\n"
|
|
279
|
+
"The draft_style_notes / edit_examples entries above are MANDATORY "
|
|
280
|
+
"for how you phrase your bridge sentence, same as they are for the "
|
|
281
|
+
"original draft; on conflict they override the example sentences "
|
|
282
|
+
"below. The audience/thread avoid-prefer entries are about "
|
|
283
|
+
"candidate judging, already applied earlier in the pipeline \u2014 not "
|
|
284
|
+
"your job here.\n"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return f"""You are writing ONLY the bridge sentence that gets APPENDED after a social media reply we already drafted (shown below). This is a one-shot task. Output ONLY the bridge sentence itself: no preamble, no explanation, no quotes, and do NOT repeat, paraphrase, or rewrite any part of the reply below \u2014 it is already final and will be appended before your output automatically, verbatim.
|
|
233
288
|
|
|
234
289
|
PLATFORM: {platform}
|
|
235
290
|
PROJECT: {project}
|
|
@@ -237,33 +292,32 @@ LANDING PAGE URL: {link_url}
|
|
|
237
292
|
|
|
238
293
|
{voice_rule}
|
|
239
294
|
|
|
295
|
+
{learned_prefs_section}
|
|
296
|
+
|
|
240
297
|
{length_rule}
|
|
241
298
|
|
|
242
299
|
ORIGINAL THREAD WE ARE REPLYING TO:
|
|
243
300
|
{thread_text}
|
|
244
301
|
|
|
245
|
-
REPLY
|
|
302
|
+
REPLY ALREADY DRAFTED (fixed, DO NOT repeat or modify \u2014 your sentence is appended after this automatically):
|
|
246
303
|
{reply_text}
|
|
247
304
|
|
|
248
305
|
YOUR TASK:
|
|
249
|
-
|
|
250
|
-
1. References the SINGLE strongest specific claim, mechanism, or detail from the
|
|
251
|
-
2. Names a CONCRETE PRODUCT MECHANISM that delivers it (verb + noun, inferred from the URL slug + project context). Do NOT say "a tool for this", "something that helps", "made this for it"
|
|
306
|
+
Write ONE bridge sentence (\u2264 22 words) that:
|
|
307
|
+
1. References the SINGLE strongest specific claim, mechanism, or detail from the reply above (e.g. "rephrasing on revisit", "a 4-axis rubric", "200ms p95", "automatic distractor scoring") \u2014 pick ONE concrete thing, not a category.
|
|
308
|
+
2. Names a CONCRETE PRODUCT MECHANISM that delivers it (verb + noun, inferred from the URL slug + project context). Do NOT say "a tool for this", "something that helps", "made this for it" \u2014 those are banned.
|
|
252
309
|
3. Ends with the URL exactly as given. No period after. No "click here", "check it out", "give it a try".
|
|
253
|
-
4. Reads in the voice of the reply (lowercase if reply is lowercase, casual if reply is casual).
|
|
310
|
+
4. Reads in the voice of the reply above (lowercase if reply is lowercase, casual if reply is casual).
|
|
254
311
|
5. Obeys the VOICE RELATIONSHIP rule above. This rule overrides any default phrasing instinct.
|
|
255
|
-
|
|
256
|
-
REPLACEMENT RULE:
|
|
257
|
-
- If the reply has a clear empathy/advice body, KEEP that body verbatim and append the bridge as a new sentence (separated by a single space).
|
|
258
|
-
- If the reply already trails off with weak filler, REPLACE just the trailing weak portion with the bridge.
|
|
312
|
+
6. Obeys the LEARNED PREFERENCES above when present. This rule overrides the example sentences below on conflict.
|
|
259
313
|
|
|
260
314
|
OUTPUT FORMAT (strict):
|
|
261
|
-
Output
|
|
315
|
+
Output ONLY your bridge sentence, ending in the URL, on a single line. Nothing else: no JSON, no markdown, no quotes, and absolutely none of the reply text shown above.
|
|
262
316
|
|
|
263
|
-
Example bridge sentences (do NOT copy verbatim
|
|
317
|
+
Example bridge sentences (do NOT copy verbatim \u2014 these are FORM examples, voice-matched to this project):
|
|
264
318
|
{example_block}
|
|
265
319
|
|
|
266
|
-
Write the
|
|
320
|
+
Write the bridge sentence now."""
|
|
267
321
|
|
|
268
322
|
|
|
269
323
|
def _unwrap_queue_envelope(text: str) -> str:
|
|
@@ -411,52 +465,51 @@ THIRD_PARTY_VOICE_VIOLATIONS = (
|
|
|
411
465
|
)
|
|
412
466
|
|
|
413
467
|
|
|
414
|
-
def passes_quality_gate(
|
|
468
|
+
def passes_quality_gate(bridge: str, link_url: str,
|
|
415
469
|
voice_relationship: str = "first_party",
|
|
416
|
-
limit: int | None = None
|
|
417
470
|
) -> tuple[bool, str]:
|
|
418
471
|
"""Return (passes, reason_if_not).
|
|
419
472
|
|
|
473
|
+
Validates the model-written BRIDGE SENTENCE ALONE. reply_text is trusted,
|
|
474
|
+
already-vetted content that this function never sees and never judges —
|
|
475
|
+
the append-only contract means reply_text can't fail this gate, only the
|
|
476
|
+
newly generated bridge can.
|
|
477
|
+
|
|
420
478
|
Hard rules:
|
|
421
479
|
- must contain link_url
|
|
422
480
|
- must end with link_url (allow trailing whitespace, nothing else)
|
|
423
481
|
- must NOT contain banned phrases
|
|
424
|
-
- must not be
|
|
482
|
+
- must not be wildly longer than a sentence (MAX_BRIDGE_CHARS): the
|
|
483
|
+
model ignored the "bridge sentence only" contract and is attempting
|
|
484
|
+
a full rewrite — a structural violation, not a phrasing nitpick
|
|
425
485
|
- on third_party projects, must NOT use first-person-plural product
|
|
426
486
|
ownership phrases ("we ship", "we built", "our product", ...). The
|
|
427
|
-
link_tail prompt
|
|
487
|
+
link_tail prompt selects voice-matched examples but the model can
|
|
428
488
|
still drift; on violation we fall back to the mechanical concat so
|
|
429
489
|
the post still ships without impersonating the client (root cause of
|
|
430
490
|
the 2026-05-27 Agora OODAO incident).
|
|
431
491
|
"""
|
|
432
|
-
if not
|
|
492
|
+
if not bridge:
|
|
433
493
|
return (False, "empty")
|
|
434
|
-
if link_url not in
|
|
494
|
+
if link_url not in bridge:
|
|
435
495
|
return (False, "no_url")
|
|
436
496
|
# Trailing-URL check (nothing meaningful after URL, optional ./! is fine
|
|
437
497
|
# to strip; but our prompt forbids trailing period — so just check no
|
|
438
498
|
# alphanumeric content follows).
|
|
439
|
-
tail =
|
|
499
|
+
tail = bridge.split(link_url, 1)[1].strip()
|
|
440
500
|
if tail and re.search(r"[A-Za-z0-9]", tail):
|
|
441
501
|
return (False, f"content_after_url: {tail[:40]!r}")
|
|
442
|
-
lower =
|
|
502
|
+
lower = bridge.lower()
|
|
443
503
|
for phrase in BANNED_PHRASES:
|
|
444
504
|
if phrase in lower:
|
|
445
505
|
return (False, f"banned_phrase: {phrase!r}")
|
|
446
506
|
if voice_relationship == "third_party":
|
|
447
507
|
for rx in THIRD_PARTY_VOICE_VIOLATIONS:
|
|
448
|
-
m = rx.search(
|
|
508
|
+
m = rx.search(bridge)
|
|
449
509
|
if m:
|
|
450
510
|
return (False, f"third_party_voice_violation: {m.group(0)!r}")
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
return (False, "too_short")
|
|
454
|
-
# Upper-length backstop (twitter): X-weighted length must fit the cap. The
|
|
455
|
-
# caller trims the body to fit BEFORE the gate, so this should only trip on
|
|
456
|
-
# a degenerate trim; on trip we fall back to the (also budget-enforced)
|
|
457
|
-
# mechanical concat.
|
|
458
|
-
if limit is not None and x_weighted_len(final_text) > limit:
|
|
459
|
-
return (False, f"too_long:{x_weighted_len(final_text)}>{limit}")
|
|
511
|
+
if len(bridge) > MAX_BRIDGE_CHARS:
|
|
512
|
+
return (False, f"bridge_too_long:{len(bridge)}>{MAX_BRIDGE_CHARS}")
|
|
460
513
|
return (True, "")
|
|
461
514
|
|
|
462
515
|
|
|
@@ -530,14 +583,43 @@ def main() -> int:
|
|
|
530
583
|
print(json.dumps(out), flush=True)
|
|
531
584
|
return 0
|
|
532
585
|
|
|
533
|
-
|
|
586
|
+
project_entry = _load_project_entry(args.project)
|
|
587
|
+
voice_relationship = args.voice_relationship or resolve_voice_relationship(project_entry)
|
|
588
|
+
learned_prefs_block = resolve_learned_preferences_block(project_entry)
|
|
534
589
|
# Length cap is X-specific; reddit/linkedin pass None (no tail trim).
|
|
535
590
|
limit = TWEET_LIMIT if args.platform == "twitter" else None
|
|
591
|
+
|
|
592
|
+
# Deterministic no-room guard: if the already-drafted reply alone leaves
|
|
593
|
+
# no meaningful space for a bridge sentence before the (flat-weighted)
|
|
594
|
+
# URL, skip the Claude call outright — a squeezed-in fragment is worse
|
|
595
|
+
# than no bridge — and fall back to the mechanical concat like any other
|
|
596
|
+
# failure path.
|
|
597
|
+
bridge_budget = None
|
|
598
|
+
if limit is not None:
|
|
599
|
+
bridge_budget = limit - URL_WEIGHT - 1 - x_weighted_len(reply_text)
|
|
600
|
+
if bridge_budget < MIN_BRIDGE_CHARS:
|
|
601
|
+
fb_text, fb_trim = enforce_budget(
|
|
602
|
+
mechanical_fallback(reply_text, link_url), link_url, limit)
|
|
603
|
+
out = {
|
|
604
|
+
"ok": True,
|
|
605
|
+
"text": fb_text,
|
|
606
|
+
"tail": link_url,
|
|
607
|
+
"model_call_ok": False,
|
|
608
|
+
"fallback_used": True,
|
|
609
|
+
"budget_trimmed": fb_trim,
|
|
610
|
+
"error": f"no_room_for_bridge:{bridge_budget}",
|
|
611
|
+
"elapsed_sec": 0.0,
|
|
612
|
+
}
|
|
613
|
+
print(json.dumps(out), flush=True)
|
|
614
|
+
return 0
|
|
615
|
+
|
|
536
616
|
prompt = build_prompt(
|
|
537
617
|
reply_text=reply_text, link_url=link_url,
|
|
538
618
|
thread_text=(args.thread_text or "").strip()[:2000],
|
|
539
619
|
project=args.project, platform=args.platform,
|
|
540
620
|
voice_relationship=voice_relationship,
|
|
621
|
+
learned_prefs_block=learned_prefs_block,
|
|
622
|
+
bridge_budget=bridge_budget,
|
|
541
623
|
)
|
|
542
624
|
|
|
543
625
|
started = time.time()
|
|
@@ -562,16 +644,9 @@ def main() -> int:
|
|
|
562
644
|
print(json.dumps(out), flush=True)
|
|
563
645
|
return 0
|
|
564
646
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
# a few chars over. No-op on non-twitter (limit is None).
|
|
569
|
-
budget_trimmed = False
|
|
570
|
-
if limit is not None:
|
|
571
|
-
cleaned, budget_trimmed = enforce_budget(cleaned, link_url, limit)
|
|
572
|
-
passes, reason = passes_quality_gate(cleaned, link_url,
|
|
573
|
-
voice_relationship=voice_relationship,
|
|
574
|
-
limit=limit)
|
|
647
|
+
bridge = clean_output(raw)
|
|
648
|
+
passes, reason = passes_quality_gate(bridge, link_url,
|
|
649
|
+
voice_relationship=voice_relationship)
|
|
575
650
|
if not passes:
|
|
576
651
|
fb_text, fb_trim = enforce_budget(
|
|
577
652
|
mechanical_fallback(reply_text, link_url), link_url,
|
|
@@ -590,23 +665,24 @@ def main() -> int:
|
|
|
590
665
|
print(json.dumps(out), flush=True)
|
|
591
666
|
return 0
|
|
592
667
|
|
|
593
|
-
# Successful path:
|
|
594
|
-
#
|
|
595
|
-
|
|
596
|
-
#
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
668
|
+
# Successful path: append the model-written bridge to the UNMODIFIED
|
|
669
|
+
# reply_text. This is the deterministic append-only guarantee — the
|
|
670
|
+
# model's output was validated above as a standalone sentence and is
|
|
671
|
+
# never substituted for reply_text, only concatenated after it.
|
|
672
|
+
final_text = f"{reply_text.rstrip()} {bridge}".strip()
|
|
673
|
+
budget_trimmed = False
|
|
674
|
+
if limit is not None:
|
|
675
|
+
# Belt-and-suspenders: should be a no-op given the bridge_budget
|
|
676
|
+
# precheck above, unless the model ignored the length rule. Trims
|
|
677
|
+
# from the END of the pre-URL text (the model's own bridge, appended
|
|
678
|
+
# last), so a well-behaved reply_text is only touched in the
|
|
679
|
+
# degenerate case where reply_text alone already exceeds the limit.
|
|
680
|
+
final_text, budget_trimmed = enforce_budget(final_text, link_url, limit)
|
|
605
681
|
|
|
606
682
|
out = {
|
|
607
683
|
"ok": True,
|
|
608
|
-
"text":
|
|
609
|
-
"tail":
|
|
684
|
+
"text": final_text,
|
|
685
|
+
"tail": bridge,
|
|
610
686
|
"model_call_ok": True,
|
|
611
687
|
"fallback_used": False,
|
|
612
688
|
"budget_trimmed": budget_trimmed,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bracket a HOST-NATIVE tool call (create_scheduled_task, list_scheduled_tasks,
|
|
3
|
+
update_scheduled_task) with an explicit start/end marker.
|
|
4
|
+
|
|
5
|
+
WHY THIS EXISTS: those tools run entirely inside the Claude Desktop host
|
|
6
|
+
process, invisible to S4L's own tool-call telemetry (which only sees our own
|
|
7
|
+
MCP tools, e.g. queue_setup). During the Karol 2026-07-07 update-orphan
|
|
8
|
+
incident, create_scheduled_task and list_scheduled_tasks each silently hung
|
|
9
|
+
for 35-56 minutes with zero signal anywhere except manually reading gaps
|
|
10
|
+
between transcript message timestamps after the fact. This script is meant to
|
|
11
|
+
be run by the AGENT (via Bash), bracketing exactly those calls, per the
|
|
12
|
+
instructions baked into REARM_PROMPT / DIAGNOSE_PROMPT_TEMPLATE
|
|
13
|
+
(mcp/menubar/s4l_menubar.py) and the dashboard panel's copy (mcp/panel/panel.ts).
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
mark_event.py start <tool_name> # call immediately BEFORE the host tool call
|
|
17
|
+
mark_event.py end <tool_name> # call immediately AFTER it returns
|
|
18
|
+
|
|
19
|
+
State: one small JSON file per tool_name under
|
|
20
|
+
~/.social-autoposter-mcp/host-tool-calls/<tool_name>.json. 'start' writes it;
|
|
21
|
+
'end' reads + removes it and computes the elapsed duration. A mismatched or
|
|
22
|
+
missing start on 'end' still reports (duration unknown) rather than silently
|
|
23
|
+
no-op'ing, so a broken bracket is itself visible in the log line.
|
|
24
|
+
|
|
25
|
+
Best-effort end to end: never raises, never blocks the caller, exits 0 always
|
|
26
|
+
(a failure here must not stop the actual scheduled-task registration).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
# Only page/record when the call was slow enough to matter — routine
|
|
37
|
+
# sub-second calls shouldn't generate a Sentry event on every re-arm. 120s
|
|
38
|
+
# matches the "consecutive check" cadence used elsewhere in this codebase
|
|
39
|
+
# (e.g. autopilot_stall_watch.py's launchd interval).
|
|
40
|
+
SLOW_THRESHOLD_S = 120.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _state_dir() -> str:
|
|
44
|
+
return os.path.join(os.path.expanduser("~"), ".social-autoposter-mcp", "host-tool-calls")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _state_path(tool_name: str) -> str:
|
|
48
|
+
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in tool_name)
|
|
49
|
+
return os.path.join(_state_dir(), f"{safe}.json")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _report(message: str, level: str, tags: dict, extra: dict | None = None) -> None:
|
|
53
|
+
try:
|
|
54
|
+
repo = os.environ.get("S4L_REPO_DIR")
|
|
55
|
+
if repo:
|
|
56
|
+
scripts_dir = os.path.join(repo, "scripts")
|
|
57
|
+
if scripts_dir not in sys.path:
|
|
58
|
+
sys.path.insert(0, scripts_dir)
|
|
59
|
+
else:
|
|
60
|
+
# No S4L_REPO_DIR in this chat turn's env — fall back to this
|
|
61
|
+
# script's own directory, which IS scripts/ (sentry_init lives
|
|
62
|
+
# right next to it).
|
|
63
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
64
|
+
if here not in sys.path:
|
|
65
|
+
sys.path.insert(0, here)
|
|
66
|
+
import sentry_init
|
|
67
|
+
|
|
68
|
+
sentry_init.init()
|
|
69
|
+
sentry_init.capture_message(message, level=level, tags=tags, extra=extra)
|
|
70
|
+
sentry_init.flush()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_start(tool_name: str) -> int:
|
|
76
|
+
try:
|
|
77
|
+
os.makedirs(_state_dir(), exist_ok=True)
|
|
78
|
+
tmp = f"{_state_path(tool_name)}.tmp.{os.getpid()}"
|
|
79
|
+
with open(tmp, "w") as f:
|
|
80
|
+
json.dump({"tool_name": tool_name, "started_at": time.time()}, f)
|
|
81
|
+
os.replace(tmp, _state_path(tool_name))
|
|
82
|
+
except Exception as e:
|
|
83
|
+
sys.stderr.write(f"[mark-event] start write failed for {tool_name}: {e}\n")
|
|
84
|
+
sys.stderr.write(f"[mark-event] host tool call started: {tool_name}\n")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_end(tool_name: str) -> int:
|
|
89
|
+
started_at = None
|
|
90
|
+
try:
|
|
91
|
+
with open(_state_path(tool_name)) as f:
|
|
92
|
+
started_at = (json.load(f) or {}).get("started_at")
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
try:
|
|
96
|
+
os.remove(_state_path(tool_name))
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
duration = (time.time() - started_at) if started_at else None
|
|
101
|
+
if duration is not None:
|
|
102
|
+
sys.stderr.write(f"[mark-event] host tool call finished: {tool_name} took {duration:.1f}s\n")
|
|
103
|
+
if duration > SLOW_THRESHOLD_S:
|
|
104
|
+
_report(
|
|
105
|
+
f"S4L host tool call was slow: {tool_name} took {duration:.1f}s",
|
|
106
|
+
level="warning",
|
|
107
|
+
tags={"component": "host_tool_timing", "tool_name": tool_name},
|
|
108
|
+
extra={"duration_seconds": round(duration, 1)},
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
sys.stderr.write(f"[mark-event] host tool call finished: {tool_name} (no matching start)\n")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> int:
|
|
116
|
+
if len(sys.argv) != 3 or sys.argv[1] not in ("start", "end"):
|
|
117
|
+
sys.stderr.write("usage: mark_event.py start|end <tool_name>\n")
|
|
118
|
+
return 0 # never a hard failure for the calling agent
|
|
119
|
+
action, tool_name = sys.argv[1], sys.argv[2]
|
|
120
|
+
return cmd_start(tool_name) if action == "start" else cmd_end(tool_name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
try:
|
|
125
|
+
sys.exit(main())
|
|
126
|
+
except Exception as e:
|
|
127
|
+
sys.stderr.write(f"[mark-event] unexpected error: {e}\n")
|
|
128
|
+
sys.exit(0)
|
|
@@ -604,6 +604,7 @@ def build_snapshot(top_n: int) -> dict[str, Any]:
|
|
|
604
604
|
"repo_dir": str(REPO_DIR),
|
|
605
605
|
"claude_desktop_version": claude_desktop_version(),
|
|
606
606
|
"reaper": reaper_status(),
|
|
607
|
+
"menubar": menubar_status(),
|
|
607
608
|
"memory": parse_vm_stat(),
|
|
608
609
|
"process_count": len(rows),
|
|
609
610
|
"top_rss": [
|
|
@@ -696,7 +697,9 @@ def build_summary() -> dict[str, Any]:
|
|
|
696
697
|
"app_version": _app_version(),
|
|
697
698
|
"claude_desktop_version": claude_desktop_version(),
|
|
698
699
|
"reaper": reaper_status(),
|
|
700
|
+
"menubar": menubar_status(),
|
|
699
701
|
"twitter_cycle": twitter_cycle_status(),
|
|
702
|
+
"draft_publish": draft_publish_wrapper_status(),
|
|
700
703
|
"process_count": len(rows),
|
|
701
704
|
"mem": {
|
|
702
705
|
"total_mb": total,
|
|
@@ -765,6 +768,39 @@ def claude_desktop_version() -> str | None:
|
|
|
765
768
|
return None
|
|
766
769
|
|
|
767
770
|
|
|
771
|
+
def menubar_status() -> dict[str, Any] | None:
|
|
772
|
+
"""Last status-item visibility probe written by s4l_menubar._icon_watch_tick(),
|
|
773
|
+
or None. A live menubar PROCESS says nothing about whether macOS is drawing
|
|
774
|
+
its icon (2026-07-07: a customer's menubar ran ~25 min icon-less after a
|
|
775
|
+
reboot and support had no remote signal). Carrying this on the heartbeat
|
|
776
|
+
makes "process up, icon hidden" diagnosable centrally. age_sec doubles as a
|
|
777
|
+
process-liveness hint: the menubar rewrites the file every ~60s, so a stale
|
|
778
|
+
file means the menubar itself is down."""
|
|
779
|
+
path = (
|
|
780
|
+
Path(os.environ.get("S4L_STATE_DIR", str(Path.home() / ".social-autoposter-mcp")))
|
|
781
|
+
/ "menubar-state.json"
|
|
782
|
+
)
|
|
783
|
+
try:
|
|
784
|
+
if not path.exists():
|
|
785
|
+
return None
|
|
786
|
+
ds = json.loads(path.read_text())
|
|
787
|
+
age = None
|
|
788
|
+
try:
|
|
789
|
+
age = round(time.time() - path.stat().st_mtime, 1)
|
|
790
|
+
except OSError:
|
|
791
|
+
pass
|
|
792
|
+
return {
|
|
793
|
+
"age_sec": age, # >150s means the menubar process itself is likely dead
|
|
794
|
+
"pid": ds.get("pid"),
|
|
795
|
+
"uptime_sec": ds.get("uptime_sec"),
|
|
796
|
+
"statusitem_on_screen": ds.get("statusitem_on_screen"),
|
|
797
|
+
"statusitem_visible_pref": ds.get("statusitem_visible_pref"),
|
|
798
|
+
"hidden_for_sec": ds.get("hidden_for_sec"),
|
|
799
|
+
}
|
|
800
|
+
except Exception:
|
|
801
|
+
return None
|
|
802
|
+
|
|
803
|
+
|
|
768
804
|
def reaper_status() -> dict[str, Any] | None:
|
|
769
805
|
"""Last cycle written by reap_stale_claude_sessions.py::write_status(), or None.
|
|
770
806
|
|
|
@@ -836,6 +872,32 @@ def twitter_cycle_status() -> dict[str, Any] | None:
|
|
|
836
872
|
return None
|
|
837
873
|
|
|
838
874
|
|
|
875
|
+
def draft_publish_wrapper_status() -> dict[str, Any] | None:
|
|
876
|
+
"""Tail of run-draft-and-publish.sh's launchd stderr log.
|
|
877
|
+
|
|
878
|
+
run-twitter-cycle.sh's OWN log (see twitter_cycle_status above) only shows
|
|
879
|
+
that a cycle drafted; it says nothing about whether those drafts actually
|
|
880
|
+
became review cards. That decision happens one level up, in the
|
|
881
|
+
[run-draft-and-publish]/[merge_review_queue] lines this wrapper prints to
|
|
882
|
+
its own stderr (draft-only on/off, posting-defer skips, merge success/
|
|
883
|
+
failure/count). Those lines land in a real file via the launchd plist's
|
|
884
|
+
StandardErrorPath, but nothing centrally tailed them, which blinded the
|
|
885
|
+
2026-07-08 Karol investigation to whether merges were succeeding even
|
|
886
|
+
though the inner cycle log looked healthy. Best-effort."""
|
|
887
|
+
try:
|
|
888
|
+
p = REPO_DIR / "skill" / "logs" / "launchd-twitter-cycle-stderr.log"
|
|
889
|
+
if not p.exists():
|
|
890
|
+
return None
|
|
891
|
+
lines = [ln.strip() for ln in _tail_lines(p, 16) if ln.strip()]
|
|
892
|
+
return {
|
|
893
|
+
"log": p.name,
|
|
894
|
+
"age_sec": round(time.time() - p.stat().st_mtime, 1),
|
|
895
|
+
"last_lines": [ln[:200] for ln in lines[-8:]],
|
|
896
|
+
}
|
|
897
|
+
except Exception:
|
|
898
|
+
return None
|
|
899
|
+
|
|
900
|
+
|
|
839
901
|
def _tail_lines(path: Path, n: int, approx_line_bytes: int = 4096) -> list[str]:
|
|
840
902
|
"""Return the last `n` lines of a possibly-large file without reading it all.
|
|
841
903
|
Reads a bounded tail window (n * approx_line_bytes) from the end. Best-effort."""
|
|
@@ -154,7 +154,7 @@ def main():
|
|
|
154
154
|
sys.exit(3)
|
|
155
155
|
|
|
156
156
|
project_recents = recent_angles_by_project(ctx.get("project_angles"))
|
|
157
|
-
candidates = build_candidates(config, project_recents)
|
|
157
|
+
candidates, _ = build_candidates(config, project_recents)
|
|
158
158
|
recent_project_counts = ctx.get("recent_posts_by_project") or {}
|
|
159
159
|
|
|
160
160
|
if args.show_all:
|