social-autoposter 1.6.81 → 1.6.82
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 +95 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +11 -9
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/twitter_post_plan.py +65 -20
package/mcp/dist/index.js
CHANGED
|
@@ -1424,6 +1424,96 @@ async function autopilotLoaded() {
|
|
|
1424
1424
|
}
|
|
1425
1425
|
return { autopilot_on, auto_update_on };
|
|
1426
1426
|
}
|
|
1427
|
+
// ---- Autopilot prompt: keep the scheduled task's SKILL.md current ------------
|
|
1428
|
+
// The scheduled task's prompt (SKILL.md) is owned by the host and does NOT update
|
|
1429
|
+
// when the plugin updates. So a behavior change (the no-improvise / voice-inline /
|
|
1430
|
+
// stop-cleanly rules) would never reach an already-scheduled task. We close that
|
|
1431
|
+
// gap by REWRITING the task's SKILL.md on server boot when its embedded prompt
|
|
1432
|
+
// version is older than what this build ships. The host reads the file fresh on
|
|
1433
|
+
// each run, so the next fire picks up the new prompt — no host tool, no user
|
|
1434
|
+
// action. Bump AUTOPILOT_PROMPT_VERSION whenever the prompt below changes.
|
|
1435
|
+
const AUTOPILOT_PROMPT_VERSION = 1;
|
|
1436
|
+
const AUTOPILOT_PROMPT_MARKER = "saps_autopilot_prompt_version";
|
|
1437
|
+
function autopilotSkillPath() {
|
|
1438
|
+
const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
1439
|
+
return path.join(cfg, "scheduled-tasks", AUTOPILOT_TASK_ID, "SKILL.md");
|
|
1440
|
+
}
|
|
1441
|
+
// The canonical draft-only autopilot prompt: uses ONLY scan_candidates +
|
|
1442
|
+
// submit_drafts, drafts from the INLINE project_voices, improvises nothing, and
|
|
1443
|
+
// stops cleanly if its tools aren't available.
|
|
1444
|
+
function autopilotSkillMd(project) {
|
|
1445
|
+
const body = [
|
|
1446
|
+
`You are running the Social Autoposter draft-only autopilot for the project "${project}". ` +
|
|
1447
|
+
`Run ONE cycle, then stop. DRAFT-ONLY: you must NEVER post to X/Twitter — you only queue ` +
|
|
1448
|
+
`drafts for the user's approval. Use ONLY two tools — scan_candidates and submit_drafts — ` +
|
|
1449
|
+
`and IMPROVISE NOTHING ELSE.`,
|
|
1450
|
+
``,
|
|
1451
|
+
`Steps:`,
|
|
1452
|
+
`1. Call scan_candidates with project "${project}". It long-polls: if it returns a "Scan in ` +
|
|
1453
|
+
`progress" status, call scan_candidates again (same args) and keep re-calling until it ` +
|
|
1454
|
+
`returns candidates. Never sleep or use background waits. If it returns no candidates, stop ` +
|
|
1455
|
+
`cleanly — nothing to do this cycle.`,
|
|
1456
|
+
`2. The result includes a project_voices field — the brand voice + guardrails for each ` +
|
|
1457
|
+
`candidate's project. Draft from project_voices[<matched_project>] (voice, description, ` +
|
|
1458
|
+
`differentiator, content_guardrails). NEVER read config files, call project_config, or run ` +
|
|
1459
|
+
`Bash/Read to find the voice — everything you need is already in the scan result.`,
|
|
1460
|
+
`3. For each candidate you judge genuinely worth engaging, write ONE on-brand reply (<=250 ` +
|
|
1461
|
+
`chars, match the thread's language, genuinely helpful — not spammy, no hard selling). Skip ` +
|
|
1462
|
+
`the rest.`,
|
|
1463
|
+
`4. Call submit_drafts with the batch_id from scan_candidates and a drafts array of ` +
|
|
1464
|
+
`{candidate_id, reply_text}. This queues them for the menu-bar approval UI. Do NOT call ` +
|
|
1465
|
+
`post_drafts — posting is the user's decision.`,
|
|
1466
|
+
``,
|
|
1467
|
+
`HARD GUARD: if scan_candidates is NOT available (ToolSearch returns no matching tool — can ` +
|
|
1468
|
+
`happen when a run spawns before the extension's MCP server has reconnected), STOP immediately ` +
|
|
1469
|
+
`and report "S4L tools unavailable, skipping this cycle." Do NOT search the connector registry, ` +
|
|
1470
|
+
`do NOT call list_connectors, do NOT improvise any other tool.`,
|
|
1471
|
+
].join("\n");
|
|
1472
|
+
return (`---\n` +
|
|
1473
|
+
`name: ${AUTOPILOT_TASK_ID}\n` +
|
|
1474
|
+
`description: Social Autoposter draft-only autopilot for project ${project} — scans X, drafts ` +
|
|
1475
|
+
`on-brand replies (voice inline), queues them for approval. Never posts.\n` +
|
|
1476
|
+
`---\n\n` +
|
|
1477
|
+
body +
|
|
1478
|
+
`\n\n<!-- ${AUTOPILOT_PROMPT_MARKER}: ${AUTOPILOT_PROMPT_VERSION} -->\n`);
|
|
1479
|
+
}
|
|
1480
|
+
// Read the project name back from an existing autopilot SKILL.md so a rewrite
|
|
1481
|
+
// keeps the same project. Null if it can't be determined (then leave it alone).
|
|
1482
|
+
function detectAutopilotProject(skillMd) {
|
|
1483
|
+
const m1 = /project[s]?\s+"([^"]+)"/i.exec(skillMd);
|
|
1484
|
+
if (m1)
|
|
1485
|
+
return m1[1];
|
|
1486
|
+
const m2 = /for project\s+([A-Za-z0-9_-]+)/i.exec(skillMd);
|
|
1487
|
+
if (m2)
|
|
1488
|
+
return m2[1];
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
// Refresh the scheduled task's SKILL.md when this build ships a newer prompt than
|
|
1492
|
+
// what's on disk. Best-effort, synchronous, never throws. Only touches an EXISTING
|
|
1493
|
+
// task (onboarding creates the first one), only when stale, and only when the
|
|
1494
|
+
// project reads back, so we never write a wrong/blank project.
|
|
1495
|
+
function ensureAutopilotPromptCurrent() {
|
|
1496
|
+
try {
|
|
1497
|
+
const skillPath = autopilotSkillPath();
|
|
1498
|
+
if (!fs.existsSync(skillPath))
|
|
1499
|
+
return; // no scheduled task yet
|
|
1500
|
+
const cur = fs.readFileSync(skillPath, "utf-8");
|
|
1501
|
+
const m = new RegExp(`${AUTOPILOT_PROMPT_MARKER}:\\s*(\\d+)`).exec(cur);
|
|
1502
|
+
const curVer = m ? parseInt(m[1], 10) : 0;
|
|
1503
|
+
if (curVer >= AUTOPILOT_PROMPT_VERSION)
|
|
1504
|
+
return; // already current
|
|
1505
|
+
const project = detectAutopilotProject(cur);
|
|
1506
|
+
if (!project) {
|
|
1507
|
+
console.error(`[autopilot] prompt is stale (v${curVer}) but project unreadable from SKILL.md; leaving it`);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
fs.writeFileSync(skillPath, autopilotSkillMd(project), "utf-8");
|
|
1511
|
+
console.error(`[autopilot] refreshed scheduled-task prompt -> v${AUTOPILOT_PROMPT_VERSION} (was v${curVer}), project=${project}`);
|
|
1512
|
+
}
|
|
1513
|
+
catch (e) {
|
|
1514
|
+
console.error(`[autopilot] ensureAutopilotPromptCurrent error: ${e?.message || e}`);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1427
1517
|
// Assemble everything the panel needs in one shot (projects + X + autopilot +
|
|
1428
1518
|
// version). Resilient: any probe that throws degrades to a safe default rather
|
|
1429
1519
|
// than failing the whole snapshot.
|
|
@@ -2261,6 +2351,11 @@ async function main() {
|
|
|
2261
2351
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
2262
2352
|
// the version first materialized at install). Synchronous + best-effort.
|
|
2263
2353
|
ensurePipelineCurrent();
|
|
2354
|
+
// Same problem for the scheduled task's prompt: the host owns its SKILL.md and
|
|
2355
|
+
// a plugin update never touches it. Rewrite it on boot when this build ships a
|
|
2356
|
+
// newer prompt version, so behavior changes (no-improvise / voice-inline /
|
|
2357
|
+
// stop-cleanly) reach an already-scheduled task on its next fire. Best-effort.
|
|
2358
|
+
ensureAutopilotPromptCurrent();
|
|
2264
2359
|
const transport = new StdioServerTransport();
|
|
2265
2360
|
await server.connect(transport);
|
|
2266
2361
|
console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
|
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.6.
|
|
5
|
+
"version": "1.6.82",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
|
|
7
7
|
"long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
|
@@ -47,7 +47,6 @@ SETUP_PROMPT = (
|
|
|
47
47
|
"must interactively sign in or no product can be identified."
|
|
48
48
|
)
|
|
49
49
|
DRAFT_PROMPT = "Run a social-autoposter draft cycle and show me the drafts to review."
|
|
50
|
-
POST_PROMPT = "Post my approved social-autoposter drafts."
|
|
51
50
|
UPDATE_PROMPT = "Update social-autoposter to the latest version."
|
|
52
51
|
|
|
53
52
|
|
|
@@ -183,9 +182,6 @@ class S4LMenuBar(rumps.App):
|
|
|
183
182
|
def _draft(self, _=None):
|
|
184
183
|
self._send_to_claude(DRAFT_PROMPT)
|
|
185
184
|
|
|
186
|
-
def _post(self, _=None):
|
|
187
|
-
self._send_to_claude(POST_PROMPT)
|
|
188
|
-
|
|
189
185
|
def _update(self, _=None):
|
|
190
186
|
self._send_to_claude(UPDATE_PROMPT)
|
|
191
187
|
|
|
@@ -319,8 +315,13 @@ class S4LMenuBar(rumps.App):
|
|
|
319
315
|
act = st.read_activity()
|
|
320
316
|
label = act.get("label") if act else None
|
|
321
317
|
if label:
|
|
322
|
-
|
|
323
|
-
|
|
318
|
+
# A "✓" label (e.g. "posted 3/10 ✓") is a momentary confirmation, not
|
|
319
|
+
# ongoing work — show it without the spinner glyph so it reads as done.
|
|
320
|
+
if "✓" in label:
|
|
321
|
+
self.title = f"S4L {label}"
|
|
322
|
+
else:
|
|
323
|
+
self._spin_i = (self._spin_i + 1) % len(SPINNER)
|
|
324
|
+
self.title = f"S4L {label} {SPINNER[self._spin_i]}"
|
|
324
325
|
return
|
|
325
326
|
try:
|
|
326
327
|
if self._spinner is not None:
|
|
@@ -589,9 +590,10 @@ class S4LMenuBar(rumps.App):
|
|
|
589
590
|
out.append(
|
|
590
591
|
rumps.MenuItem("Run draft cycle in Claude", callback=self._draft)
|
|
591
592
|
)
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
)
|
|
593
|
+
# No "Post approved drafts" item: approving a review card already posts
|
|
594
|
+
# directly + programmatically (_on_review_done -> st.post_drafts -> the
|
|
595
|
+
# CDP poster). A menu button that types a prompt into the chat to do the
|
|
596
|
+
# same thing was a redundant detour through the model, so it's gone.
|
|
595
597
|
return out
|
|
596
598
|
|
|
597
599
|
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/social-autoposter-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.82",
|
|
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
|
@@ -45,6 +45,7 @@ import random
|
|
|
45
45
|
import re
|
|
46
46
|
import subprocess
|
|
47
47
|
import sys
|
|
48
|
+
import time
|
|
48
49
|
from datetime import datetime, timezone
|
|
49
50
|
from pathlib import Path
|
|
50
51
|
|
|
@@ -845,6 +846,39 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
845
846
|
return ("posted", "")
|
|
846
847
|
|
|
847
848
|
|
|
849
|
+
def _saps_state_dir() -> str:
|
|
850
|
+
return os.environ.get("SAPS_STATE_DIR") or os.path.join(
|
|
851
|
+
os.path.expanduser("~"), ".social-autoposter-mcp")
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _write_activity(label: str) -> None:
|
|
855
|
+
"""Best-effort live status for the S4L menu bar, which polls
|
|
856
|
+
<state_dir>/activity.json. Mirrors the Node server's writeActivity shape so
|
|
857
|
+
the menu-bar spinner renders our per-post progress ("posting 3/10", then
|
|
858
|
+
"posted 3/10 ✓"). Purely cosmetic: a failure here never affects posting."""
|
|
859
|
+
try:
|
|
860
|
+
sd = _saps_state_dir()
|
|
861
|
+
os.makedirs(sd, exist_ok=True)
|
|
862
|
+
payload = {"state": "working", "label": label,
|
|
863
|
+
"since": datetime.now(timezone.utc).isoformat()}
|
|
864
|
+
with open(os.path.join(sd, "activity.json"), "w", encoding="utf-8") as f:
|
|
865
|
+
f.write(json.dumps(payload) + "\n")
|
|
866
|
+
except Exception:
|
|
867
|
+
pass
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _clear_activity() -> None:
|
|
871
|
+
"""Remove our status so neither an early exit nor the cron path (which does
|
|
872
|
+
NOT go through the MCP runTool's clear) leaves a stale 'posting/posted' stuck
|
|
873
|
+
in the menu bar. Double-clearing with runTool is harmless."""
|
|
874
|
+
try:
|
|
875
|
+
p = os.path.join(_saps_state_dir(), "activity.json")
|
|
876
|
+
if os.path.exists(p):
|
|
877
|
+
os.remove(p)
|
|
878
|
+
except Exception:
|
|
879
|
+
pass
|
|
880
|
+
|
|
881
|
+
|
|
848
882
|
def main() -> int:
|
|
849
883
|
ap = argparse.ArgumentParser()
|
|
850
884
|
ap.add_argument("--plan", required=True,
|
|
@@ -924,26 +958,37 @@ def main() -> int:
|
|
|
924
958
|
f"approved in plan (pass --post-unapproved to override)", flush=True)
|
|
925
959
|
candidates = _kept
|
|
926
960
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
961
|
+
_total = len(candidates)
|
|
962
|
+
try:
|
|
963
|
+
for _idx, c in enumerate(candidates, start=1):
|
|
964
|
+
# Live per-post status for the S4L menu bar: "posting 3/10" while this
|
|
965
|
+
# one is in flight, then "posted 3/10 ✓" once it lands. Cosmetic only.
|
|
966
|
+
_write_activity(f"posting {_idx}/{_total}")
|
|
967
|
+
try:
|
|
968
|
+
outcome, reason = post_one(c, picker_assignment=picker_assignment)
|
|
969
|
+
except Exception as e:
|
|
970
|
+
print(f"[post] candidate {c.get('candidate_id')} crashed: {e}",
|
|
971
|
+
flush=True)
|
|
972
|
+
outcome, reason = ("failed", "exception")
|
|
973
|
+
cid = c.get("candidate_id")
|
|
974
|
+
if isinstance(cid, int):
|
|
975
|
+
update_candidate(cid, "skipped", "exception")
|
|
976
|
+
if outcome == "posted":
|
|
977
|
+
posted += 1
|
|
978
|
+
# Flash the confirmation with a short dwell so the menu bar shows
|
|
979
|
+
# it before the next iteration's "posting" overwrites the label.
|
|
980
|
+
_write_activity(f"posted {_idx}/{_total} ✓")
|
|
981
|
+
time.sleep(0.6)
|
|
982
|
+
elif outcome == "skipped":
|
|
983
|
+
skipped += 1
|
|
984
|
+
if reason:
|
|
985
|
+
skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
|
|
986
|
+
else:
|
|
987
|
+
failed += 1
|
|
988
|
+
if reason:
|
|
989
|
+
fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
|
|
990
|
+
finally:
|
|
991
|
+
_clear_activity()
|
|
947
992
|
|
|
948
993
|
summary = {
|
|
949
994
|
"posted": posted,
|