@m13v/s4l 1.7.5-rc.13 → 1.7.5-rc.15
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 +127 -73
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +14 -5
- package/mcp/menubar/s4l_state.py +3 -120
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/browser_mutex.py +316 -0
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/reddit_browser.py +27 -60
- package/scripts/reddit_browser_fetch.py +32 -0
- package/scripts/release-mcpb.sh +17 -0
- package/scripts/scan_twitter_thread_followups.py +7 -1
- package/scripts/trigger_promo_lane_test.sh +91 -0
- package/scripts/twitter_browser.py +26 -340
- package/scripts/twitter_scan.py +19 -11
- package/skill/lib/harness-common.sh +7 -0
- package/skill/lib/linkedin-backend.sh +18 -0
- package/skill/lock.sh +57 -0
package/mcp/dist/index.js
CHANGED
|
@@ -1014,95 +1014,149 @@ async function postApproved(batchId, plan) {
|
|
|
1014
1014
|
const approvedTwitter = approved.filter((c) => c.platform !== "reddit");
|
|
1015
1015
|
let redditPosted = 0;
|
|
1016
1016
|
let redditFailed = 0;
|
|
1017
|
+
let redditSkippedPeerDrain = false;
|
|
1017
1018
|
if (approvedReddit.length) {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
: dec.text,
|
|
1036
|
-
};
|
|
1037
|
-
if (cc.engagement_style)
|
|
1038
|
-
decPost.engagement_style = cc.engagement_style;
|
|
1039
|
-
const miniPlan = {
|
|
1040
|
-
project_name: meta.project_name || cc.matched_project,
|
|
1041
|
-
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1042
|
-
phase: "draft",
|
|
1043
|
-
decisions: [decPost],
|
|
1044
|
-
style_assignment: meta.style_assignment || {},
|
|
1045
|
-
generation_trace_path: meta.generation_trace_path,
|
|
1046
|
-
session_id: meta.session_id || meta.draft_session_id,
|
|
1047
|
-
};
|
|
1048
|
-
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1049
|
-
let r;
|
|
1019
|
+
// Cross-instance drain gate (2026-07-14 drain storm): every one-shot MCP
|
|
1020
|
+
// boot (the queue worker fires ~every minute; each boot runs the startup
|
|
1021
|
+
// backlog drain) drained the SAME sticky approvals concurrently — 26
|
|
1022
|
+
// drain plans in ~30 minutes, all fighting over the one reddit tab and
|
|
1023
|
+
// false-positiving each other's posts. reddit-posting-active.json is the
|
|
1024
|
+
// arbiter: post_reddit.py heartbeats it per row, and we stamp it around
|
|
1025
|
+
// the whole drain here. A fresh flag (<120s) means a peer is mid-drain:
|
|
1026
|
+
// leave the cards for it — approvals are sticky, nothing is lost.
|
|
1027
|
+
const redditFlagPath = path.join(s4lStateDir(), "reddit-posting-active.json");
|
|
1028
|
+
let redditPeerFresh = false;
|
|
1029
|
+
try {
|
|
1030
|
+
redditPeerFresh = Date.now() - fs.statSync(redditFlagPath).mtimeMs < 120_000;
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
redditPeerFresh = false;
|
|
1034
|
+
}
|
|
1035
|
+
const stampRedditFlag = () => {
|
|
1050
1036
|
try {
|
|
1051
|
-
fs.writeFileSync(
|
|
1052
|
-
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1053
|
-
timeoutMs: 900_000,
|
|
1054
|
-
env: {
|
|
1055
|
-
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1056
|
-
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1057
|
-
// as the twitter manual-approval path below).
|
|
1058
|
-
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1059
|
-
},
|
|
1060
|
-
onLine: (line) => {
|
|
1061
|
-
const t = line.replace(/\s+$/, "");
|
|
1062
|
-
if (t.trim())
|
|
1063
|
-
console.error(`[post-reddit] ${t}`);
|
|
1064
|
-
},
|
|
1065
|
-
});
|
|
1037
|
+
fs.writeFileSync(redditFlagPath, JSON.stringify({ pid: process.pid, hb: Math.floor(Date.now() / 1000) }));
|
|
1066
1038
|
}
|
|
1067
|
-
catch
|
|
1068
|
-
|
|
1039
|
+
catch {
|
|
1040
|
+
/* best effort */
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
if (redditPeerFresh) {
|
|
1044
|
+
redditSkippedPeerDrain = true;
|
|
1045
|
+
logPostEvent(`postApproved_reddit_skip batch=${batchId} reason=peer_drain_active cards=${approvedReddit.length}`);
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
stampRedditFlag();
|
|
1049
|
+
try {
|
|
1050
|
+
for (const c of approvedReddit) {
|
|
1051
|
+
stampRedditFlag();
|
|
1052
|
+
const cc = c;
|
|
1053
|
+
const dec = cc.reddit_decision;
|
|
1054
|
+
const meta = (cc.reddit_plan_meta || {});
|
|
1055
|
+
if (!dec) {
|
|
1056
|
+
c.terminal = true;
|
|
1057
|
+
c.terminal_reason = "reddit_decision_missing";
|
|
1058
|
+
redditFailed++;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
// The card's reply_text is the CANONICAL text (post_drafts edits write
|
|
1062
|
+
// there); the embedded decision still carries the draft-time original.
|
|
1063
|
+
// Posting dec.text verbatim would silently discard a human edit.
|
|
1064
|
+
const decPost = {
|
|
1065
|
+
...dec,
|
|
1066
|
+
text: (typeof cc.reply_text === "string" && cc.reply_text.trim())
|
|
1067
|
+
? cc.reply_text
|
|
1068
|
+
: dec.text,
|
|
1069
|
+
};
|
|
1070
|
+
if (cc.engagement_style)
|
|
1071
|
+
decPost.engagement_style = cc.engagement_style;
|
|
1072
|
+
const miniPlan = {
|
|
1073
|
+
project_name: meta.project_name || cc.matched_project,
|
|
1074
|
+
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1075
|
+
phase: "draft",
|
|
1076
|
+
decisions: [decPost],
|
|
1077
|
+
style_assignment: meta.style_assignment || {},
|
|
1078
|
+
generation_trace_path: meta.generation_trace_path,
|
|
1079
|
+
session_id: meta.session_id || meta.draft_session_id,
|
|
1080
|
+
};
|
|
1081
|
+
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1082
|
+
let r;
|
|
1083
|
+
try {
|
|
1084
|
+
fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
|
|
1085
|
+
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1086
|
+
timeoutMs: 900_000,
|
|
1087
|
+
env: {
|
|
1088
|
+
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1089
|
+
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1090
|
+
// as the twitter manual-approval path below).
|
|
1091
|
+
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1092
|
+
},
|
|
1093
|
+
onLine: (line) => {
|
|
1094
|
+
const t = line.replace(/\s+$/, "");
|
|
1095
|
+
if (t.trim())
|
|
1096
|
+
console.error(`[post-reddit] ${t}`);
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
r = { code: -1, stdout: "", stderr: String(err) };
|
|
1102
|
+
}
|
|
1103
|
+
finally {
|
|
1104
|
+
try {
|
|
1105
|
+
fs.unlinkSync(tmpPlan);
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
/* best effort */
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
|
|
1112
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
1113
|
+
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1114
|
+
if (r.code === 0 && postedN > 0) {
|
|
1115
|
+
c.posted = true;
|
|
1116
|
+
c.terminal = false;
|
|
1117
|
+
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1118
|
+
if (urlMatch)
|
|
1119
|
+
c.our_url = urlMatch[1];
|
|
1120
|
+
redditPosted++;
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1124
|
+
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1125
|
+
// semantics; only stamp terminal on a conclusive CDP refusal.
|
|
1126
|
+
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1127
|
+
if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
|
|
1128
|
+
c.terminal = true;
|
|
1129
|
+
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1130
|
+
}
|
|
1131
|
+
redditFailed++;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1069
1134
|
}
|
|
1070
1135
|
finally {
|
|
1136
|
+
// Clear only OUR stamp; a peer that took over mid-drain keeps its own.
|
|
1071
1137
|
try {
|
|
1072
|
-
fs.
|
|
1138
|
+
const cur = JSON.parse(fs.readFileSync(redditFlagPath, "utf-8"));
|
|
1139
|
+
if (cur && cur.pid === process.pid)
|
|
1140
|
+
fs.unlinkSync(redditFlagPath);
|
|
1073
1141
|
}
|
|
1074
1142
|
catch {
|
|
1075
1143
|
/* best effort */
|
|
1076
1144
|
}
|
|
1077
1145
|
}
|
|
1078
|
-
|
|
1079
|
-
const out = `${r.stdout}\n${r.stderr}`;
|
|
1080
|
-
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1081
|
-
if (r.code === 0 && postedN > 0) {
|
|
1082
|
-
c.posted = true;
|
|
1083
|
-
c.terminal = false;
|
|
1084
|
-
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1085
|
-
if (urlMatch)
|
|
1086
|
-
c.our_url = urlMatch[1];
|
|
1087
|
-
redditPosted++;
|
|
1088
|
-
}
|
|
1089
|
-
else {
|
|
1090
|
-
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1091
|
-
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1092
|
-
// semantics; only stamp terminal on a conclusive CDP refusal.
|
|
1093
|
-
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1094
|
-
if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
|
|
1095
|
-
c.terminal = true;
|
|
1096
|
-
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1097
|
-
}
|
|
1098
|
-
redditFailed++;
|
|
1099
|
-
}
|
|
1146
|
+
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1100
1147
|
}
|
|
1101
|
-
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1102
1148
|
}
|
|
1103
1149
|
if (approvedTwitter.length === 0) {
|
|
1104
1150
|
// All-reddit batch: persist the stamps and return without touching the
|
|
1105
1151
|
// twitter preflight/lock path.
|
|
1152
|
+
if (redditSkippedPeerDrain) {
|
|
1153
|
+
return {
|
|
1154
|
+
attempted: 0,
|
|
1155
|
+
posted: 0,
|
|
1156
|
+
exit_code: 0,
|
|
1157
|
+
summary: "reddit: skipped (peer drain active); cards stay approved for the next drain",
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1106
1160
|
if (approvedReddit.length)
|
|
1107
1161
|
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1108
1162
|
return {
|
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.5-rc.
|
|
5
|
+
"version": "1.7.5-rc.15",
|
|
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": {
|
|
@@ -3148,17 +3148,19 @@ class S4LMenuBar(rumps.App):
|
|
|
3148
3148
|
self._ship_review_event(batch, decision)
|
|
3149
3149
|
if not decision.get("approved"):
|
|
3150
3150
|
n = decision.get("n")
|
|
3151
|
-
# Durable local record FIRST, mirroring
|
|
3151
|
+
# Durable local record FIRST, mirroring the approve branch below.
|
|
3152
3152
|
# review_drafts() consults this, so the rejected card is suppressed from
|
|
3153
3153
|
# re-review IMMEDIATELY and even if the loopback is down when the
|
|
3154
3154
|
# background plan-flag write below runs. Without this, a reject was a
|
|
3155
3155
|
# fire-and-forget loopback call with a swallowed exception, so rejects
|
|
3156
3156
|
# silently vanished and the card "came back" — unlike durable approvals.
|
|
3157
|
+
# A failure here is captured (not silently swallowed) so a broken
|
|
3158
|
+
# durable write is visible instead of looking identical to success.
|
|
3157
3159
|
try:
|
|
3158
3160
|
st.store_stamp_decision(batch, decision)
|
|
3159
3161
|
self._session_decisions.append(dict(decision))
|
|
3160
|
-
except Exception:
|
|
3161
|
-
|
|
3162
|
+
except Exception as e:
|
|
3163
|
+
_capture(e, phase="reject_stamp_decision")
|
|
3162
3164
|
|
|
3163
3165
|
def _persist_reject():
|
|
3164
3166
|
try:
|
|
@@ -3173,8 +3175,15 @@ class S4LMenuBar(rumps.App):
|
|
|
3173
3175
|
# restart resumes the drain instead of stranding it and re-presenting the
|
|
3174
3176
|
# card. The in-memory _post_q below is just the fast path; this file is the
|
|
3175
3177
|
# source of truth review_drafts() consults to avoid re-showing it.
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
+
# Same try/except shape as the reject branch above (was previously
|
|
3179
|
+
# unguarded here, an asymmetry with no intentional reason): a failure is
|
|
3180
|
+
# captured, not silently swallowed, and the post still gets enqueued below
|
|
3181
|
+
# either way — this write is a resume/dedup aid, not a gate on posting.
|
|
3182
|
+
try:
|
|
3183
|
+
st.store_stamp_decision(batch, decision)
|
|
3184
|
+
self._session_decisions.append(dict(decision))
|
|
3185
|
+
except Exception as e:
|
|
3186
|
+
_capture(e, phase="approve_stamp_decision")
|
|
3178
3187
|
with self._review_lock:
|
|
3179
3188
|
self._posts_outstanding += 1
|
|
3180
3189
|
self._posting_batch_total += 1
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -209,15 +209,6 @@ def runtime_ready() -> bool:
|
|
|
209
209
|
return bool(py and os.path.exists(py))
|
|
210
210
|
|
|
211
211
|
|
|
212
|
-
def _launchctl_list() -> str:
|
|
213
|
-
try:
|
|
214
|
-
return subprocess.run(
|
|
215
|
-
["launchctl", "list"], capture_output=True, text=True, timeout=10
|
|
216
|
-
).stdout
|
|
217
|
-
except Exception:
|
|
218
|
-
return ""
|
|
219
|
-
|
|
220
|
-
|
|
221
212
|
def autopilot_loaded() -> bool:
|
|
222
213
|
# Autopilot is now the Claude Desktop scheduled task, not the legacy launchd job.
|
|
223
214
|
cfg = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(str(Path.home()), ".claude")
|
|
@@ -456,23 +447,6 @@ def snapshot():
|
|
|
456
447
|
return out
|
|
457
448
|
|
|
458
449
|
|
|
459
|
-
def stats_7d():
|
|
460
|
-
"""7-day post stats; loopback only (the DB read needs the owned runtime)."""
|
|
461
|
-
res = loopback_tool("get_stats", {"days": 7})
|
|
462
|
-
if not isinstance(res, dict):
|
|
463
|
-
return None
|
|
464
|
-
projects = res.get("projects")
|
|
465
|
-
proj = projects[0] if isinstance(projects, list) and projects else None
|
|
466
|
-
p = (proj or {}).get("posts")
|
|
467
|
-
if not p:
|
|
468
|
-
return None
|
|
469
|
-
return {
|
|
470
|
-
"posts": p.get("total", 0),
|
|
471
|
-
"views": p.get("views_period_total", p.get("views", 0)),
|
|
472
|
-
"replies": p.get("comments_period_total", p.get("comments", 0)),
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
|
|
476
450
|
# set_autopilot() (the launchd toggle) was removed: the autopilot is now the Claude
|
|
477
451
|
# Desktop scheduled task `social-autoposter-autopilot`, managed in the Scheduled tab,
|
|
478
452
|
# so the menu bar no longer toggles a launchd job (it mirrors the dashboard instead).
|
|
@@ -1028,8 +1002,9 @@ def review_drafts(plan, batch="review-queue"):
|
|
|
1028
1002
|
must never be re-presented for review (approved ones proceed to post).
|
|
1029
1003
|
|
|
1030
1004
|
Also excludes cards with ANY durable decision (approved, edited, rejected, or a
|
|
1031
|
-
decided-but-failed post) via
|
|
1032
|
-
|
|
1005
|
+
decided-but-failed post) via _ledger_items_for_plan() below. approve/reject/edit
|
|
1006
|
+
each write a durable local record via store_stamp_decision() the INSTANT the
|
|
1007
|
+
user clicks (see s4l_menubar.py::_on_card_decision), so a
|
|
1033
1008
|
decided card never re-presents even if the loopback (Claude Desktop) is down
|
|
1034
1009
|
when the decision's plan-flag write is attempted. The main plan's
|
|
1035
1010
|
`approved`/`terminal`/`posted` flags are only stamped once the loopback write
|
|
@@ -1386,75 +1361,6 @@ def toggle_lane(lane):
|
|
|
1386
1361
|
return write_flags(f["personal_brand"], f["promotion"])
|
|
1387
1362
|
|
|
1388
1363
|
|
|
1389
|
-
def toggle_mode():
|
|
1390
|
-
"""Legacy whole-mode flip (mutually exclusive). Kept for old callers."""
|
|
1391
|
-
new = (
|
|
1392
|
-
MODE_PROMOTION
|
|
1393
|
-
if read_mode() == MODE_PERSONAL_BRAND
|
|
1394
|
-
else MODE_PERSONAL_BRAND
|
|
1395
|
-
)
|
|
1396
|
-
return write_mode(new)
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
def review_reject_add(batch, n, candidate_id=None):
|
|
1400
|
-
"""Record a REJECT the INSTANT the user clicks, mirroring approved_queue_add.
|
|
1401
|
-
Reject and approve are now IDENTICAL: both write a durable local record before
|
|
1402
|
-
any loopback call, so review_drafts() suppresses the card even if the loopback
|
|
1403
|
-
(Claude Desktop) is down when the reject's plan `terminal` write is attempted.
|
|
1404
|
-
Dedups on (batch, n) within the CURRENT plan generation (a stale item's n
|
|
1405
|
-
refers to a dead plan and must not absorb this decision); a reject is FINAL
|
|
1406
|
-
and overrides any earlier status."""
|
|
1407
|
-
with _approved_lock:
|
|
1408
|
-
gen = _plan_generation(batch)
|
|
1409
|
-
d = read_approved_queue()
|
|
1410
|
-
for it in d["items"]:
|
|
1411
|
-
if it.get("batch") == batch and it.get("n") == n and not _stale_for_plan(it, gen):
|
|
1412
|
-
if it.get("status") != "rejected":
|
|
1413
|
-
it.update(status="rejected", error=None, ts=time_iso())
|
|
1414
|
-
_write_approved_queue(d)
|
|
1415
|
-
return
|
|
1416
|
-
d["items"].append({
|
|
1417
|
-
"batch": batch, "n": n, "candidate_id": candidate_id,
|
|
1418
|
-
"text": "", "edited": False,
|
|
1419
|
-
"drop_link": False, "candidate_url": "", "status": "rejected",
|
|
1420
|
-
"error": None, "ts": time_iso(),
|
|
1421
|
-
})
|
|
1422
|
-
_write_approved_queue(d)
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
def approved_queue_add(batch, n, text="", edited=False, candidate_url="", drop_link=False,
|
|
1426
|
-
candidate_id=None):
|
|
1427
|
-
"""Record an approval the INSTANT the user clicks, before any posting. Dedups
|
|
1428
|
-
on (batch, n) within the CURRENT plan generation: re-approving a card that's
|
|
1429
|
-
still queued/posting/posted is a no-op; a previously FAILED card is reset to
|
|
1430
|
-
queued so it retries. A stale item (previous plan generation, same n) must
|
|
1431
|
-
not absorb the approval or the fresh card would silently never post.
|
|
1432
|
-
|
|
1433
|
-
candidate_id is the durable identity of the decision (twitter_candidates
|
|
1434
|
-
row); n is only meaningful within one plan generation.
|
|
1435
|
-
|
|
1436
|
-
drop_link carries the user's "I deleted the link while editing" intent so a
|
|
1437
|
-
restart-resumed post honors it too (else the poster re-appends the link)."""
|
|
1438
|
-
with _approved_lock:
|
|
1439
|
-
gen = _plan_generation(batch)
|
|
1440
|
-
d = read_approved_queue()
|
|
1441
|
-
for it in d["items"]:
|
|
1442
|
-
if it.get("batch") == batch and it.get("n") == n and not _stale_for_plan(it, gen):
|
|
1443
|
-
if it.get("status") == "failed":
|
|
1444
|
-
it.update(status="queued", text=text, edited=bool(edited),
|
|
1445
|
-
drop_link=bool(drop_link), error=None, ts=time_iso())
|
|
1446
|
-
_write_approved_queue(d)
|
|
1447
|
-
return
|
|
1448
|
-
d["items"].append({
|
|
1449
|
-
"batch": batch, "n": n, "candidate_id": candidate_id,
|
|
1450
|
-
"text": text, "edited": bool(edited),
|
|
1451
|
-
"drop_link": bool(drop_link),
|
|
1452
|
-
"candidate_url": candidate_url, "status": "queued",
|
|
1453
|
-
"error": None, "ts": time_iso(),
|
|
1454
|
-
})
|
|
1455
|
-
_write_approved_queue(d)
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
1364
|
def approved_queue_set_status(batch, n, status, error=None):
|
|
1459
1365
|
with _approved_lock:
|
|
1460
1366
|
d = read_approved_queue()
|
|
@@ -1474,29 +1380,6 @@ def approved_queue_pending():
|
|
|
1474
1380
|
if it.get("status") in ("queued", "posting")]
|
|
1475
1381
|
|
|
1476
1382
|
|
|
1477
|
-
def approved_queue_active_ns(batch):
|
|
1478
|
-
"""Plan indices the user has already approved for this batch — review_drafts()
|
|
1479
|
-
excludes these so an approved card is never re-shown. Covers queued/posting
|
|
1480
|
-
(in flight) AND posted: relying on the plan's posted flag alone leaves a window
|
|
1481
|
-
(and breaks if the plan is regenerated), so the durable queue excludes posted
|
|
1482
|
-
cards independently. `failed` is intentionally NOT excluded, so a failed post
|
|
1483
|
-
falls back to manual review rather than silently vanishing."""
|
|
1484
|
-
gen = _plan_generation(batch)
|
|
1485
|
-
return {it.get("n") for it in read_approved_queue()["items"]
|
|
1486
|
-
if it.get("batch") == batch and it.get("status") in ("queued", "posting", "posted")
|
|
1487
|
-
and not _stale_for_plan(it, gen)}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
def review_settled_ns(batch):
|
|
1491
|
-
"""Plan indices with ANY durable user DECISION for this batch — review_drafts()
|
|
1492
|
-
excludes these so approve, edit, and reject behave IDENTICALLY: a decided card
|
|
1493
|
-
never re-presents for review. Covers queued/posting/posted (approved, in flight
|
|
1494
|
-
or landed), `rejected`, AND `failed` (a decided-but-failed post is surfaced via
|
|
1495
|
-
the failure notification + dashboard, NOT by re-showing it as a fresh review
|
|
1496
|
-
card — that re-show was the "I already decided these came back" bug)."""
|
|
1497
|
-
return {it.get("n") for it in _ledger_items_for_plan(batch, _plan_generation(batch))}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
1383
|
def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
|
|
1501
1384
|
"""Post / reject drafts via the loopback tool. `post` = 1-based numbers to post
|
|
1502
1385
|
as-is; `edits` = [{n, text}] to rewrite then post; `reject` = numbers to mark
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.5-rc.
|
|
3
|
+
"version": "1.7.5-rc.15",
|
|
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