@m13v/s4l 1.7.4 → 1.7.5-rc.10
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 +241 -61
- package/mcp/dist/panel.html +30 -16
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/setup.js +91 -5
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +5 -1
- package/mcp/menubar/s4l_card.py +32 -6
- package/mcp/menubar/s4l_menubar.py +74 -1
- package/mcp/menubar/s4l_state.py +76 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +3 -6
- package/scripts/claude_job.py +21 -0
- package/scripts/config.py +118 -0
- package/scripts/engage_github.py +7 -3
- package/scripts/engage_reddit.py +9 -3
- package/scripts/learned_preferences.py +28 -7
- package/scripts/memory_snapshot.py +45 -0
- package/scripts/merge_review_queue.py +67 -3
- package/scripts/post_reddit.py +247 -24
- package/scripts/s4l_posting_mode.py +59 -0
- package/scripts/schedule_state.py +1 -1
- package/scripts/stats.py +20 -0
- package/scripts/twitter_browser.py +170 -3
- package/scripts/watchdog_hung_runs.py +15 -9
- package/skill/lib/harness-common.sh +337 -0
- package/skill/lib/linkedin-backend.sh +44 -156
- package/skill/lib/reddit-backend.sh +21 -121
- package/skill/lib/twitter-backend.sh +51 -273
- package/skill/run-reddit-search.sh +28 -0
package/mcp/dist/index.js
CHANGED
|
@@ -885,6 +885,57 @@ function expiredStampOverridable(c) {
|
|
|
885
885
|
c.posted !== true &&
|
|
886
886
|
c.discard_reason === "backend_status_expired");
|
|
887
887
|
}
|
|
888
|
+
function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
889
|
+
// Merge posted/terminal stamps into a FRESH read of the store instead of
|
|
890
|
+
// rewriting the whole plan from the copy taken minutes ago. The old
|
|
891
|
+
// whole-file write was last-writer-wins: while a batch posted, the menubar
|
|
892
|
+
// (decision re-stamps) and any peer drain also wrote the store, and
|
|
893
|
+
// whichever run finished last erased the others' posted flags (2026-07-06:
|
|
894
|
+
// card 344877 posted at 00:29Z ended `posted=None,
|
|
895
|
+
// terminal=duplicate_thread_pre_post` after a later run's stale write).
|
|
896
|
+
// Merge rules: `posted` is sticky and wins over terminal; `terminal` never
|
|
897
|
+
// overwrites a fresh `posted=true`. Fallback: candidates without a
|
|
898
|
+
// candidate_id can't be matched into the fresh copy, so keep the legacy
|
|
899
|
+
// whole-plan write for those older plans.
|
|
900
|
+
try {
|
|
901
|
+
const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
902
|
+
const fresh = mergeable ? readPlan(batchId) : null;
|
|
903
|
+
if (fresh && Array.isArray(fresh.candidates)) {
|
|
904
|
+
const freshById = new Map();
|
|
905
|
+
fresh.candidates.forEach((c) => {
|
|
906
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
907
|
+
freshById.set(String(c.candidate_id), c);
|
|
908
|
+
});
|
|
909
|
+
for (const c of stamped) {
|
|
910
|
+
const f = freshById.get(String(c.candidate_id));
|
|
911
|
+
if (!f)
|
|
912
|
+
continue;
|
|
913
|
+
if (c.posted === true) {
|
|
914
|
+
f.posted = true;
|
|
915
|
+
f.terminal = false;
|
|
916
|
+
if (c.our_url)
|
|
917
|
+
f.our_url = c.our_url;
|
|
918
|
+
}
|
|
919
|
+
else if (c.terminal === true && f.posted !== true) {
|
|
920
|
+
f.terminal = true;
|
|
921
|
+
f.terminal_reason = c.terminal_reason;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
writePlan(batchId, fresh);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
writePlan(batchId, plan);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
try {
|
|
932
|
+
writePlan(batchId, plan);
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
/* best effort */
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
888
939
|
async function postApproved(batchId, plan) {
|
|
889
940
|
// Drain serialization (2026-07-06 incident). Every call drains the WHOLE
|
|
890
941
|
// approved backlog, so overlapping drains are pure waste and actively harmful:
|
|
@@ -951,6 +1002,105 @@ async function postApproved(batchId, plan) {
|
|
|
951
1002
|
}
|
|
952
1003
|
if (approved.length === 0)
|
|
953
1004
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
1005
|
+
// ---- Reddit cards (2026-07-14): drain them FIRST, independently ----------
|
|
1006
|
+
// A reddit card carries the verbatim draft decision (reddit_decision) plus
|
|
1007
|
+
// plan metadata (reddit_plan_meta), so approval reconstructs a one-decision
|
|
1008
|
+
// plan and reuses `post_reddit.py --phase post` unchanged — per-row
|
|
1009
|
+
// reddit-browser lease, URL wrapping, campaign suffixes, and log_post all
|
|
1010
|
+
// stay in the one battle-tested poster. The twitter handle preflight and
|
|
1011
|
+
// twitter-browser lock ceremony below are twitter-only, so reddit must not
|
|
1012
|
+
// be gated on them (and an all-reddit batch returns before any of it).
|
|
1013
|
+
const approvedReddit = approved.filter((c) => c.platform === "reddit");
|
|
1014
|
+
const approvedTwitter = approved.filter((c) => c.platform !== "reddit");
|
|
1015
|
+
let redditPosted = 0;
|
|
1016
|
+
let redditFailed = 0;
|
|
1017
|
+
if (approvedReddit.length) {
|
|
1018
|
+
for (const c of approvedReddit) {
|
|
1019
|
+
const cc = c;
|
|
1020
|
+
const dec = cc.reddit_decision;
|
|
1021
|
+
const meta = (cc.reddit_plan_meta || {});
|
|
1022
|
+
if (!dec) {
|
|
1023
|
+
c.terminal = true;
|
|
1024
|
+
c.terminal_reason = "reddit_decision_missing";
|
|
1025
|
+
redditFailed++;
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
const miniPlan = {
|
|
1029
|
+
project_name: meta.project_name || cc.matched_project,
|
|
1030
|
+
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1031
|
+
phase: "draft",
|
|
1032
|
+
decisions: [dec],
|
|
1033
|
+
style_assignment: meta.style_assignment || {},
|
|
1034
|
+
generation_trace_path: meta.generation_trace_path,
|
|
1035
|
+
session_id: meta.session_id || meta.draft_session_id,
|
|
1036
|
+
};
|
|
1037
|
+
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1038
|
+
let r;
|
|
1039
|
+
try {
|
|
1040
|
+
fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
|
|
1041
|
+
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1042
|
+
timeoutMs: 900_000,
|
|
1043
|
+
env: {
|
|
1044
|
+
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1045
|
+
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1046
|
+
// as the twitter manual-approval path below).
|
|
1047
|
+
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1048
|
+
},
|
|
1049
|
+
onLine: (line) => {
|
|
1050
|
+
const t = line.replace(/\s+$/, "");
|
|
1051
|
+
if (t.trim())
|
|
1052
|
+
console.error(`[post-reddit] ${t}`);
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
catch (err) {
|
|
1057
|
+
r = { code: -1, stdout: "", stderr: String(err) };
|
|
1058
|
+
}
|
|
1059
|
+
finally {
|
|
1060
|
+
try {
|
|
1061
|
+
fs.unlinkSync(tmpPlan);
|
|
1062
|
+
}
|
|
1063
|
+
catch {
|
|
1064
|
+
/* best effort */
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
|
|
1068
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
1069
|
+
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1070
|
+
if (r.code === 0 && postedN > 0) {
|
|
1071
|
+
c.posted = true;
|
|
1072
|
+
c.terminal = false;
|
|
1073
|
+
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1074
|
+
if (urlMatch)
|
|
1075
|
+
c.our_url = urlMatch[1];
|
|
1076
|
+
redditPosted++;
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1080
|
+
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1081
|
+
// semantics; only stamp terminal on a conclusive CDP refusal.
|
|
1082
|
+
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1083
|
+
if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
|
|
1084
|
+
c.terminal = true;
|
|
1085
|
+
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1086
|
+
}
|
|
1087
|
+
redditFailed++;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1091
|
+
}
|
|
1092
|
+
if (approvedTwitter.length === 0) {
|
|
1093
|
+
// All-reddit batch: persist the stamps and return without touching the
|
|
1094
|
+
// twitter preflight/lock path.
|
|
1095
|
+
if (approvedReddit.length)
|
|
1096
|
+
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1097
|
+
return {
|
|
1098
|
+
attempted: approvedReddit.length,
|
|
1099
|
+
posted: redditPosted,
|
|
1100
|
+
exit_code: redditFailed ? 1 : 0,
|
|
1101
|
+
summary: `reddit: posted=${redditPosted} failed=${redditFailed}`,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
954
1104
|
// PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
|
|
955
1105
|
// EVERY reply with no_account_configured and the whole batch skips — invisibly.
|
|
956
1106
|
// If onboarding never persisted it, self-heal from the live session; if even that
|
|
@@ -1002,7 +1152,7 @@ async function postApproved(batchId, plan) {
|
|
|
1002
1152
|
};
|
|
1003
1153
|
}
|
|
1004
1154
|
const approvedBatch = `${batchId}_approved`;
|
|
1005
|
-
writePlan(approvedBatch, { ...plan, candidates:
|
|
1155
|
+
writePlan(approvedBatch, { ...plan, candidates: approvedTwitter });
|
|
1006
1156
|
// S4L_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
|
|
1007
1157
|
// never get the active-campaign suffix (e.g. " written with ai") appended.
|
|
1008
1158
|
// twitter_browser.py's reply handler reads this env (inherited through
|
|
@@ -1015,7 +1165,7 @@ async function postApproved(batchId, plan) {
|
|
|
1015
1165
|
const failure = {
|
|
1016
1166
|
posted: 0,
|
|
1017
1167
|
skipped: 0,
|
|
1018
|
-
failed:
|
|
1168
|
+
failed: approvedTwitter.length,
|
|
1019
1169
|
failure_reasons: "browser_bootstrap_failed",
|
|
1020
1170
|
skip_reasons: "",
|
|
1021
1171
|
};
|
|
@@ -1031,7 +1181,7 @@ async function postApproved(batchId, plan) {
|
|
|
1031
1181
|
// (Karol 2026-07-09: 0/131 posted, exit=-1). 60s/card headroom covers
|
|
1032
1182
|
// slow candidates; the 2h cap bounds a hung poster (the browser-lock
|
|
1033
1183
|
// expiry and per-reply subprocess timeouts still fire underneath).
|
|
1034
|
-
timeoutMs: Math.min(7_200_000, Math.max(900_000,
|
|
1184
|
+
timeoutMs: Math.min(7_200_000, Math.max(900_000, approvedTwitter.length * 60_000)),
|
|
1035
1185
|
env: ({
|
|
1036
1186
|
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1037
1187
|
// Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
|
|
@@ -1098,7 +1248,7 @@ async function postApproved(batchId, plan) {
|
|
|
1098
1248
|
const postLogDir = path.join(repoDir(), "skill", "logs");
|
|
1099
1249
|
fs.mkdirSync(postLogDir, { recursive: true });
|
|
1100
1250
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1101
|
-
fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${
|
|
1251
|
+
fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approvedTwitter.length} exit=${res.code} ` +
|
|
1102
1252
|
`shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
|
|
1103
1253
|
}
|
|
1104
1254
|
catch {
|
|
@@ -1121,7 +1271,7 @@ async function postApproved(batchId, plan) {
|
|
|
1121
1271
|
const realPosted = summObj && typeof summObj.posted === "number"
|
|
1122
1272
|
? summObj.posted
|
|
1123
1273
|
: res.code === 0 && !summObj
|
|
1124
|
-
?
|
|
1274
|
+
? approvedTwitter.length
|
|
1125
1275
|
: 0;
|
|
1126
1276
|
// Mark candidates according to the poster's per-candidate outcome. This keeps
|
|
1127
1277
|
// the review queue honest: posted drafts disappear as posted, terminal skips
|
|
@@ -1142,14 +1292,14 @@ async function postApproved(batchId, plan) {
|
|
|
1142
1292
|
.filter((r) => r.candidate_id && ["posted", "skipped", "failed"].includes(r.outcome))
|
|
1143
1293
|
: parsePostCandidateResults(res.stdout);
|
|
1144
1294
|
const approvedById = new Map();
|
|
1145
|
-
|
|
1295
|
+
approvedTwitter.forEach((c) => {
|
|
1146
1296
|
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
1147
1297
|
approvedById.set(String(c.candidate_id), c);
|
|
1148
1298
|
});
|
|
1149
1299
|
let touchedPlan = false;
|
|
1150
1300
|
if (resultRows.length) {
|
|
1151
1301
|
resultRows.forEach((r, idx) => {
|
|
1152
|
-
const c = approvedById.get(r.candidate_id) ||
|
|
1302
|
+
const c = approvedById.get(r.candidate_id) || approvedTwitter[idx];
|
|
1153
1303
|
if (!c)
|
|
1154
1304
|
return;
|
|
1155
1305
|
if (r.outcome === "posted") {
|
|
@@ -1169,59 +1319,14 @@ async function postApproved(batchId, plan) {
|
|
|
1169
1319
|
else if (realPosted > 0 || (res.code === 0 && !summObj)) {
|
|
1170
1320
|
// Legacy fallback for older poster output without parseable per-candidate
|
|
1171
1321
|
// lines. Mark only when we have no finer-grained signal.
|
|
1172
|
-
for (const c of
|
|
1322
|
+
for (const c of approvedTwitter)
|
|
1173
1323
|
c.posted = true;
|
|
1174
1324
|
touchedPlan = true;
|
|
1175
1325
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
// was last-writer-wins: while this batch posted, the menubar (decision
|
|
1181
|
-
// re-stamps) and any peer drain also wrote the store, and whichever run
|
|
1182
|
-
// finished last erased the others' posted flags (2026-07-06: card 344877
|
|
1183
|
-
// posted at 00:29Z ended `posted=None, terminal=duplicate_thread_pre_post`
|
|
1184
|
-
// after a later run's stale write). Merge rules: `posted` is sticky and
|
|
1185
|
-
// wins over terminal; `terminal` never overwrites a fresh `posted=true`.
|
|
1186
|
-
// Fallback: candidates without a candidate_id can't be matched into the
|
|
1187
|
-
// fresh copy, so keep the legacy whole-plan write for those older plans.
|
|
1188
|
-
const mergeable = approved.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
1189
|
-
const fresh = mergeable ? readPlan(batchId) : null;
|
|
1190
|
-
if (fresh && Array.isArray(fresh.candidates)) {
|
|
1191
|
-
const freshById = new Map();
|
|
1192
|
-
fresh.candidates.forEach((c) => {
|
|
1193
|
-
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
1194
|
-
freshById.set(String(c.candidate_id), c);
|
|
1195
|
-
});
|
|
1196
|
-
for (const c of approved) {
|
|
1197
|
-
const f = freshById.get(String(c.candidate_id));
|
|
1198
|
-
if (!f)
|
|
1199
|
-
continue;
|
|
1200
|
-
if (c.posted === true) {
|
|
1201
|
-
f.posted = true;
|
|
1202
|
-
f.terminal = false;
|
|
1203
|
-
if (c.our_url)
|
|
1204
|
-
f.our_url = c.our_url;
|
|
1205
|
-
}
|
|
1206
|
-
else if (c.terminal === true && f.posted !== true) {
|
|
1207
|
-
f.terminal = true;
|
|
1208
|
-
f.terminal_reason = c.terminal_reason;
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
writePlan(batchId, fresh);
|
|
1212
|
-
}
|
|
1213
|
-
else {
|
|
1214
|
-
writePlan(batchId, plan);
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
catch {
|
|
1218
|
-
try {
|
|
1219
|
-
writePlan(batchId, plan);
|
|
1220
|
-
}
|
|
1221
|
-
catch {
|
|
1222
|
-
/* best effort */
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1326
|
+
// Reddit stamps (set in the reddit drain above) merge alongside the twitter
|
|
1327
|
+
// ones: `approved` here spans both platforms.
|
|
1328
|
+
if (touchedPlan || redditPosted || redditFailed) {
|
|
1329
|
+
mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1225
1330
|
}
|
|
1226
1331
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
1227
1332
|
// so they never reach Sentry on their own. Capture an explicit event whenever
|
|
@@ -1240,10 +1345,10 @@ async function postApproved(batchId, plan) {
|
|
|
1240
1345
|
// actionable error on its own.
|
|
1241
1346
|
const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
|
|
1242
1347
|
if (hasRealFailure) {
|
|
1243
|
-
captureError(new Error(`post_drafts: ${realPosted}/${
|
|
1348
|
+
captureError(new Error(`post_drafts: ${realPosted}/${approvedTwitter.length} posted (exit=${res.code})`), {
|
|
1244
1349
|
component: "post",
|
|
1245
1350
|
exit_code: String(res.code),
|
|
1246
|
-
attempted: String(
|
|
1351
|
+
attempted: String(approvedTwitter.length),
|
|
1247
1352
|
posted: String(realPosted),
|
|
1248
1353
|
failure_reasons: String(summObj?.failure_reasons || ""),
|
|
1249
1354
|
skip_reasons: String(summObj?.skip_reasons || ""),
|
|
@@ -1254,9 +1359,11 @@ async function postApproved(batchId, plan) {
|
|
|
1254
1359
|
void flushLogs();
|
|
1255
1360
|
return {
|
|
1256
1361
|
attempted: approved.length,
|
|
1257
|
-
posted: realPosted,
|
|
1362
|
+
posted: realPosted + redditPosted,
|
|
1258
1363
|
exit_code: res.code,
|
|
1259
|
-
summary
|
|
1364
|
+
summary: approvedReddit.length > 0
|
|
1365
|
+
? { twitter: summary, reddit: { posted: redditPosted, failed: redditFailed } }
|
|
1366
|
+
: summary,
|
|
1260
1367
|
stderr_tail: res.stderr.split("\n").slice(-8).join("\n"),
|
|
1261
1368
|
};
|
|
1262
1369
|
}
|
|
@@ -2332,6 +2439,27 @@ tool("post_drafts", {
|
|
|
2332
2439
|
}
|
|
2333
2440
|
c.approved = false;
|
|
2334
2441
|
rejected.push(n);
|
|
2442
|
+
// Reddit cards: also retire the reddit_candidates row (permanent
|
|
2443
|
+
// mark_attempt) so Phase 0 salvage never re-pulls and re-drafts a
|
|
2444
|
+
// thread a human just rejected. Twitter gets this for free via the
|
|
2445
|
+
// review-events row flip; reddit's id-keyed flows are deliberately
|
|
2446
|
+
// firewalled (rd- prefixed ids), so this direct by-thread-url PATCH is
|
|
2447
|
+
// the reddit equivalent. Fire-and-forget: a failure leaves the row
|
|
2448
|
+
// pending, which at worst re-drafts one card next cycle.
|
|
2449
|
+
const ccr = c;
|
|
2450
|
+
if (ccr.platform === "reddit" && (ccr.thread_url || ccr.candidate_url)) {
|
|
2451
|
+
const rejUrl = String(ccr.thread_url || ccr.candidate_url);
|
|
2452
|
+
void runPython("-c", [
|
|
2453
|
+
"import sys; sys.path.insert(0, 'scripts')\n" +
|
|
2454
|
+
"from http_api import api_patch\n" +
|
|
2455
|
+
"api_patch('/api/v1/reddit-candidates/by-thread-url', " +
|
|
2456
|
+
"{'thread_url': sys.argv[1], 'action': 'mark_attempt', " +
|
|
2457
|
+
"'reason': 'human_rejected', 'permanent': True}, ok_on_404=True)",
|
|
2458
|
+
rejUrl,
|
|
2459
|
+
], { timeoutMs: 30_000 }).catch(() => {
|
|
2460
|
+
/* best effort */
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2335
2463
|
});
|
|
2336
2464
|
// Apply edits first; an edited draft is always posted.
|
|
2337
2465
|
const approve = new Set();
|
|
@@ -2676,6 +2804,58 @@ tool("restart_menubar", {
|
|
|
2676
2804
|
menubar_running: running,
|
|
2677
2805
|
});
|
|
2678
2806
|
});
|
|
2807
|
+
// ---- posting_volume: per-install posting-volume mode (virality bar) --------
|
|
2808
|
+
// Server-side throttle (2026-07-13): installations.posting_mode maps to a
|
|
2809
|
+
// virality-bar percentile on the API (high~0.90, medium~0.97, low~0.995) and
|
|
2810
|
+
// OVERRIDES the cycle driver's hardcoded percentile, so a change applies on
|
|
2811
|
+
// the next cycle with no client update. 'get' also returns per-mode estimated
|
|
2812
|
+
// posts/day replayed from THIS install's trailing-7d candidate pool.
|
|
2813
|
+
tool("posting_volume", {
|
|
2814
|
+
title: "Read or set posting volume (Aggressive / Steady / Chill)",
|
|
2815
|
+
description: "Read or set this install's posting-volume mode, the quality bar that decides how many drafts " +
|
|
2816
|
+
"per day the twitter cycle produces. Three modes, shown to users as Aggressive (~100+ posts/day), " +
|
|
2817
|
+
"Steady (~30/day, the default every install starts on), and Chill (~5/day, only the very best " +
|
|
2818
|
+
"candidates). Internally the modes are high|medium|low and both spellings are accepted. " +
|
|
2819
|
+
"action:'get' returns the current mode plus per-mode estimated " +
|
|
2820
|
+
"posts/day computed from this install's own recent candidate pool (show those numbers with the " +
|
|
2821
|
+
"Aggressive/Steady/Chill names when the user is choosing). Use when the user asks to post more, " +
|
|
2822
|
+
"post less, slow down, be more aggressive, chill out, raise the quality bar, or change posting " +
|
|
2823
|
+
"volume. Takes effect on the next cycle; in draft-review mode it equally paces how many review " +
|
|
2824
|
+
"cards appear.",
|
|
2825
|
+
inputSchema: {
|
|
2826
|
+
action: z.enum(["get", "set"]).default("get").describe("get = read mode + rates; set = change it"),
|
|
2827
|
+
mode: z
|
|
2828
|
+
.enum(["aggressive", "steady", "chill", "high", "medium", "low"])
|
|
2829
|
+
.optional()
|
|
2830
|
+
.describe("Required for action:'set'. aggressive=high, steady=medium, chill=low."),
|
|
2831
|
+
},
|
|
2832
|
+
}, async (args) => {
|
|
2833
|
+
const action = args.action || "get";
|
|
2834
|
+
// Friendly display names map onto the stored high|medium|low enum.
|
|
2835
|
+
const ALIAS = { aggressive: "high", steady: "medium", chill: "low" };
|
|
2836
|
+
if (action === "set") {
|
|
2837
|
+
if (!args.mode) {
|
|
2838
|
+
return jsonContent({ error: "mode is required for action:'set' (aggressive|steady|chill)" });
|
|
2839
|
+
}
|
|
2840
|
+
const stored = ALIAS[String(args.mode)] || String(args.mode);
|
|
2841
|
+
const r = await runPython("scripts/s4l_posting_mode.py", ["set", stored], {
|
|
2842
|
+
timeoutMs: 30_000,
|
|
2843
|
+
});
|
|
2844
|
+
try {
|
|
2845
|
+
return jsonContent(JSON.parse((r.stdout || "").trim()));
|
|
2846
|
+
}
|
|
2847
|
+
catch {
|
|
2848
|
+
return jsonContent({ error: `posting-mode set failed: ${(r.stderr || r.stdout || "").slice(0, 300)}` });
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
const r = await runPython("scripts/s4l_posting_mode.py", ["get"], { timeoutMs: 30_000 });
|
|
2852
|
+
try {
|
|
2853
|
+
return jsonContent(JSON.parse((r.stdout || "").trim()));
|
|
2854
|
+
}
|
|
2855
|
+
catch {
|
|
2856
|
+
return jsonContent({ error: `posting-mode get failed: ${(r.stderr || r.stdout || "").slice(0, 300)}` });
|
|
2857
|
+
}
|
|
2858
|
+
});
|
|
2679
2859
|
// ---- pause_s4l: temporarily stop drafting/posting, reversibly --------------
|
|
2680
2860
|
// The lighter alternative to Quit: unloads only the launchd jobs that scan,
|
|
2681
2861
|
// draft, and post (plus their support daemons), leaving Claude Desktop, the
|