@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 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: approved });
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: approved.length,
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, approved.length * 60_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=${approved.length} exit=${res.code} ` +
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
- ? approved.length
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
- approved.forEach((c) => {
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) || approved[idx];
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 approved)
1322
+ for (const c of approvedTwitter)
1173
1323
  c.posted = true;
1174
1324
  touchedPlan = true;
1175
1325
  }
1176
- if (touchedPlan) {
1177
- try {
1178
- // Merge our stamps into a FRESH read of the store instead of rewriting the
1179
- // whole plan from the copy we took minutes ago. The old whole-file write
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}/${approved.length} posted (exit=${res.code})`), {
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(approved.length),
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