social-autoposter 1.6.142 → 1.6.144
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/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +391 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/package.json +2 -1
- package/scripts/claude_job.py +66 -0
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.144",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "The disclaimer above is generic Claude boilerplate. S4L 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 end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
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.144",
|
|
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",
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
|
|
8
|
+
const PHASES = new Set(["pre_connect", "full"]);
|
|
9
|
+
|
|
10
|
+
function existsExecutable(file) {
|
|
11
|
+
if (!file || !fs.existsSync(file)) return false;
|
|
12
|
+
try {
|
|
13
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findFirst(candidates) {
|
|
21
|
+
return candidates.find(existsExecutable) || "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findPython(home, preferred) {
|
|
25
|
+
return (
|
|
26
|
+
findFirst([
|
|
27
|
+
preferred,
|
|
28
|
+
process.env.SAPS_PYTHON,
|
|
29
|
+
path.join(home, ".social-autoposter-mcp", "runtime", ".venv", "bin", "python3"),
|
|
30
|
+
"/opt/homebrew/bin/python3",
|
|
31
|
+
"/usr/local/bin/python3",
|
|
32
|
+
"/usr/bin/python3",
|
|
33
|
+
]) || "python3"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findUv(home) {
|
|
38
|
+
return findFirst([
|
|
39
|
+
path.join(home, ".local", "bin", "uv"),
|
|
40
|
+
"/opt/homebrew/bin/uv",
|
|
41
|
+
"/usr/local/bin/uv",
|
|
42
|
+
"/usr/bin/uv",
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function result(status, detail, fix) {
|
|
47
|
+
return { status, detail, ...(fix ? { fix } : {}) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runDoctorSync(options = {}) {
|
|
51
|
+
const phase = PHASES.has(options.phase) ? options.phase : "full";
|
|
52
|
+
const home = options.home || os.homedir();
|
|
53
|
+
const repoDir =
|
|
54
|
+
options.repoDir ||
|
|
55
|
+
process.env.SAPS_REPO_DIR ||
|
|
56
|
+
path.join(home, "social-autoposter");
|
|
57
|
+
const python = findPython(home, options.python);
|
|
58
|
+
const harness = path.join(home, ".local", "bin", "browser-harness");
|
|
59
|
+
const cookiesDb = path.join(
|
|
60
|
+
home,
|
|
61
|
+
".claude",
|
|
62
|
+
"browser-profiles",
|
|
63
|
+
"browser-harness",
|
|
64
|
+
"Default",
|
|
65
|
+
"Cookies"
|
|
66
|
+
);
|
|
67
|
+
const mirrorPath = path.join(
|
|
68
|
+
home,
|
|
69
|
+
".claude",
|
|
70
|
+
"browser-profiles",
|
|
71
|
+
"browser-harness.x-cookies.json"
|
|
72
|
+
);
|
|
73
|
+
const setupScript = path.join(repoDir, "scripts", "setup_twitter_auth.py");
|
|
74
|
+
const startedAt = new Date();
|
|
75
|
+
const checks = [];
|
|
76
|
+
const add = (id, name, runner) => checks.push({ id, name, runner });
|
|
77
|
+
|
|
78
|
+
const mirrorCount = () => {
|
|
79
|
+
try {
|
|
80
|
+
const data = JSON.parse(fs.readFileSync(mirrorPath, "utf8"));
|
|
81
|
+
return Array.isArray(data.cookies) ? data.cookies.length : 0;
|
|
82
|
+
} catch {
|
|
83
|
+
return -1;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
add("node", "Node.js available", () =>
|
|
88
|
+
result("pass", process.version)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
add("python", "Python 3 available", () => {
|
|
92
|
+
const r = spawnSync(python, ["--version"], {
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
timeout: 15000,
|
|
95
|
+
});
|
|
96
|
+
if (r.status === 0) {
|
|
97
|
+
return result("pass", `${(r.stdout || r.stderr).trim()} (${python})`);
|
|
98
|
+
}
|
|
99
|
+
return result(
|
|
100
|
+
"fail",
|
|
101
|
+
"python3 not found",
|
|
102
|
+
"run the owned runtime installer"
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
add("uv", "uv Python launcher installed", () => {
|
|
107
|
+
const uv = findUv(home);
|
|
108
|
+
return uv
|
|
109
|
+
? result("pass", uv)
|
|
110
|
+
: result("fail", "uv not found", "run the owned runtime installer");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
add("browser_harness", "browser-harness CLI installed", () =>
|
|
114
|
+
fs.existsSync(harness)
|
|
115
|
+
? result("pass", harness)
|
|
116
|
+
: result(
|
|
117
|
+
"fail",
|
|
118
|
+
`not found at ${harness}`,
|
|
119
|
+
"run the owned runtime installer"
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
add("browser_harness_shape", "browser-harness CLI shape", () => {
|
|
124
|
+
if (!fs.existsSync(harness)) {
|
|
125
|
+
return result("fail", "binary missing", "run the owned runtime installer");
|
|
126
|
+
}
|
|
127
|
+
const probe = spawnSync(harness, [], {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
timeout: 15000,
|
|
130
|
+
});
|
|
131
|
+
const usage = `${probe.stdout || ""}${probe.stderr || ""}`;
|
|
132
|
+
const dashC = /\b-c\b/.test(usage);
|
|
133
|
+
const stdin = /<<'PY'|<<"PY"|<<PY\b/.test(usage);
|
|
134
|
+
if (!dashC && !stdin) {
|
|
135
|
+
return result(
|
|
136
|
+
"fail",
|
|
137
|
+
"CLI advertises neither supported invocation shape",
|
|
138
|
+
"reinstall browser-harness with the owned runtime installer"
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return result("pass", stdin ? "stdin heredoc" : "-c flag");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
add("chrome_safe_storage", "Chrome Safe Storage readable", () => {
|
|
145
|
+
if (process.platform !== "darwin") {
|
|
146
|
+
return result("pass", "not applicable on this platform");
|
|
147
|
+
}
|
|
148
|
+
if (phase === "pre_connect") {
|
|
149
|
+
return result(
|
|
150
|
+
"expected",
|
|
151
|
+
"deferred until X connection to avoid an unexpected keychain prompt"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const r = spawnSync(
|
|
155
|
+
"security",
|
|
156
|
+
[
|
|
157
|
+
"find-generic-password",
|
|
158
|
+
"-s",
|
|
159
|
+
"Chrome Safe Storage",
|
|
160
|
+
"-a",
|
|
161
|
+
"Chrome",
|
|
162
|
+
"-w",
|
|
163
|
+
],
|
|
164
|
+
{ encoding: "utf8", timeout: 10000 }
|
|
165
|
+
);
|
|
166
|
+
if (r.status === 0) {
|
|
167
|
+
return result("pass", "accessible (cookie import can decrypt Chrome data)");
|
|
168
|
+
}
|
|
169
|
+
const tail =
|
|
170
|
+
(r.stderr || "").trim().split("\n").slice(-1)[0] || `exit ${r.status}`;
|
|
171
|
+
return result(
|
|
172
|
+
"fail",
|
|
173
|
+
tail,
|
|
174
|
+
"unlock the login keychain, then reconnect X"
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
add("chrome_cdp", "Managed Chrome CDP responding", () => {
|
|
179
|
+
const probe = spawnSync(
|
|
180
|
+
"curl",
|
|
181
|
+
[
|
|
182
|
+
"-sf",
|
|
183
|
+
"--max-time",
|
|
184
|
+
"2",
|
|
185
|
+
"-o",
|
|
186
|
+
"/dev/null",
|
|
187
|
+
"http://127.0.0.1:9555/json/version",
|
|
188
|
+
],
|
|
189
|
+
{ encoding: "utf8" }
|
|
190
|
+
);
|
|
191
|
+
if (probe.status === 0) return result("pass", "CDP responding on :9555");
|
|
192
|
+
if (phase === "pre_connect") {
|
|
193
|
+
return result(
|
|
194
|
+
"expected",
|
|
195
|
+
"managed Chrome is not running yet; connect_x launches it"
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return result(
|
|
199
|
+
"fail",
|
|
200
|
+
"no CDP response on :9555",
|
|
201
|
+
"re-run connect_x to launch managed Chrome"
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
add("x_session", "X session valid in managed Chrome", () => {
|
|
206
|
+
if (!fs.existsSync(setupScript)) {
|
|
207
|
+
return phase === "pre_connect"
|
|
208
|
+
? result("expected", "X status script becomes available after runtime installation")
|
|
209
|
+
: result("fail", `setup script missing at ${setupScript}`, "repair the runtime");
|
|
210
|
+
}
|
|
211
|
+
const r = spawnSync(python, [setupScript, "status"], {
|
|
212
|
+
encoding: "utf8",
|
|
213
|
+
timeout: 90000,
|
|
214
|
+
});
|
|
215
|
+
let out = null;
|
|
216
|
+
try {
|
|
217
|
+
out = JSON.parse((r.stdout || "").trim());
|
|
218
|
+
} catch {
|
|
219
|
+
out = null;
|
|
220
|
+
}
|
|
221
|
+
if (out && out.connected) {
|
|
222
|
+
return result(
|
|
223
|
+
"pass",
|
|
224
|
+
`state=${out.state}${out.handle ? ` handle=@${out.handle}` : ""}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const state = out && out.state ? out.state : "unavailable";
|
|
228
|
+
return phase === "pre_connect"
|
|
229
|
+
? result("expected", `state=${state}; X is connected later in onboarding`)
|
|
230
|
+
: result(
|
|
231
|
+
"fail",
|
|
232
|
+
`state=${state}`,
|
|
233
|
+
"run connect_x and finish any interactive X login"
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
add("x_cookie_sqlite", "X cookies persisted to Chrome SQLite", () => {
|
|
238
|
+
if (!fs.existsSync(cookiesDb)) {
|
|
239
|
+
return phase === "pre_connect"
|
|
240
|
+
? result("expected", "Chrome Cookies database has not been created yet")
|
|
241
|
+
: result("fail", `${cookiesDb} missing`, "re-run connect_x");
|
|
242
|
+
}
|
|
243
|
+
const r = spawnSync(
|
|
244
|
+
python,
|
|
245
|
+
[
|
|
246
|
+
"-c",
|
|
247
|
+
`import sqlite3; c=sqlite3.connect(${JSON.stringify(
|
|
248
|
+
cookiesDb
|
|
249
|
+
)}); print(c.execute("SELECT COUNT(*) FROM cookies WHERE host_key LIKE '%x.com' OR host_key LIKE '%twitter.com'").fetchone()[0])`,
|
|
250
|
+
],
|
|
251
|
+
{ encoding: "utf8", timeout: 10000 }
|
|
252
|
+
);
|
|
253
|
+
const count = Number.parseInt((r.stdout || "0").trim(), 10);
|
|
254
|
+
if (count > 0) {
|
|
255
|
+
return result("pass", `${count} x.com/twitter.com rows persisted`);
|
|
256
|
+
}
|
|
257
|
+
return phase === "pre_connect"
|
|
258
|
+
? result("expected", "no X cookie rows yet; connect_x imports them")
|
|
259
|
+
: result("fail", "0 x.com/twitter.com rows", "re-run connect_x");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
add("x_cookie_mirror", "Durable X cookie mirror populated", () => {
|
|
263
|
+
const count = mirrorCount();
|
|
264
|
+
if (count > 0) return result("pass", `${count} cookies mirrored`);
|
|
265
|
+
if (phase === "pre_connect") {
|
|
266
|
+
return result(
|
|
267
|
+
"expected",
|
|
268
|
+
count === 0
|
|
269
|
+
? "mirror exists but is empty until X is connected"
|
|
270
|
+
: "mirror is created during X connection"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return result(
|
|
274
|
+
"fail",
|
|
275
|
+
count === 0
|
|
276
|
+
? "mirror file is empty"
|
|
277
|
+
: `no mirror at ${mirrorPath}`,
|
|
278
|
+
"re-run connect_x to populate the durable mirror"
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
add("keychain_autolock", "Login keychain auto-lock is covered", () => {
|
|
283
|
+
if (process.platform !== "darwin") {
|
|
284
|
+
return result("pass", "not applicable on this platform");
|
|
285
|
+
}
|
|
286
|
+
const keychain = path.join(
|
|
287
|
+
home,
|
|
288
|
+
"Library",
|
|
289
|
+
"Keychains",
|
|
290
|
+
"login.keychain-db"
|
|
291
|
+
);
|
|
292
|
+
const r = spawnSync("security", ["show-keychain-info", keychain], {
|
|
293
|
+
encoding: "utf8",
|
|
294
|
+
timeout: 10000,
|
|
295
|
+
});
|
|
296
|
+
const output = `${r.stdout || ""}${r.stderr || ""}`;
|
|
297
|
+
const match = output.match(/timeout=(\d+)s/);
|
|
298
|
+
if (!match) return result("pass", "no auto-lock timeout detected");
|
|
299
|
+
const seconds = Number.parseInt(match[1], 10);
|
|
300
|
+
if (mirrorCount() > 0) {
|
|
301
|
+
return result(
|
|
302
|
+
"pass",
|
|
303
|
+
`auto-locks after ${seconds}s; durable cookie mirror covers relaunches`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return phase === "pre_connect"
|
|
307
|
+
? result(
|
|
308
|
+
"expected",
|
|
309
|
+
`auto-locks after ${seconds}s; connect_x will create the protective mirror`
|
|
310
|
+
)
|
|
311
|
+
: result(
|
|
312
|
+
"fail",
|
|
313
|
+
`auto-locks after ${seconds}s with no durable mirror`,
|
|
314
|
+
"re-run connect_x to create the mirror"
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const completedChecks = checks.map((check) => {
|
|
319
|
+
const checkStarted = Date.now();
|
|
320
|
+
try {
|
|
321
|
+
return {
|
|
322
|
+
id: check.id,
|
|
323
|
+
name: check.name,
|
|
324
|
+
...check.runner(),
|
|
325
|
+
duration_ms: Date.now() - checkStarted,
|
|
326
|
+
};
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return {
|
|
329
|
+
id: check.id,
|
|
330
|
+
name: check.name,
|
|
331
|
+
status: "fail",
|
|
332
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
333
|
+
duration_ms: Date.now() - checkStarted,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
const summary = { pass: 0, fail: 0, expected: 0, warn: 0, total: checks.length };
|
|
338
|
+
for (const check of completedChecks) {
|
|
339
|
+
if (Object.prototype.hasOwnProperty.call(summary, check.status)) {
|
|
340
|
+
summary[check.status] += 1;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const completedAt = new Date();
|
|
344
|
+
return {
|
|
345
|
+
schema_version: 1,
|
|
346
|
+
phase,
|
|
347
|
+
ok: summary.fail === 0,
|
|
348
|
+
started_at: startedAt.toISOString(),
|
|
349
|
+
completed_at: completedAt.toISOString(),
|
|
350
|
+
duration_ms: completedAt.getTime() - startedAt.getTime(),
|
|
351
|
+
summary,
|
|
352
|
+
checks: completedChecks,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function formatDoctorReport(report) {
|
|
357
|
+
const glyph = {
|
|
358
|
+
pass: "OK",
|
|
359
|
+
fail: "FAIL",
|
|
360
|
+
expected: "WAIT",
|
|
361
|
+
warn: "WARN",
|
|
362
|
+
};
|
|
363
|
+
const lines = [
|
|
364
|
+
`social-autoposter doctor — ${report.phase.replace("_", " ")} phase`,
|
|
365
|
+
"",
|
|
366
|
+
];
|
|
367
|
+
for (const check of report.checks) {
|
|
368
|
+
lines.push(
|
|
369
|
+
` [${(glyph[check.status] || check.status.toUpperCase()).padEnd(4)}] ${
|
|
370
|
+
check.name
|
|
371
|
+
}: ${check.detail || ""}`
|
|
372
|
+
);
|
|
373
|
+
if (check.fix) lines.push(` fix: ${check.fix}`);
|
|
374
|
+
}
|
|
375
|
+
lines.push(
|
|
376
|
+
"",
|
|
377
|
+
`${report.summary.pass}/${report.summary.total} passed, ` +
|
|
378
|
+
`${report.summary.expected} expected, ${report.summary.fail} failed.`
|
|
379
|
+
);
|
|
380
|
+
if (!report.ok) {
|
|
381
|
+
lines.push(
|
|
382
|
+
"Address the failures above and re-run `npx social-autoposter doctor`."
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return lines.join("\n");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = {
|
|
389
|
+
formatDoctorReport,
|
|
390
|
+
runDoctorSync,
|
|
391
|
+
};
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
|
|
8
|
+
const SCHEMA_VERSION = 1;
|
|
9
|
+
const MILESTONES = [
|
|
10
|
+
"environment_checked",
|
|
11
|
+
"runtime_ready",
|
|
12
|
+
"x_connected",
|
|
13
|
+
"profile_scanned",
|
|
14
|
+
"mode_chosen",
|
|
15
|
+
"project_ready",
|
|
16
|
+
"topics_seeded",
|
|
17
|
+
"tasks_scheduled",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function stateDir(opts = {}) {
|
|
21
|
+
return (
|
|
22
|
+
opts.stateDir ||
|
|
23
|
+
process.env.SAPS_STATE_DIR ||
|
|
24
|
+
path.join(os.homedir(), ".social-autoposter-mcp")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ledgerPath(opts = {}) {
|
|
29
|
+
return path.join(stateDir(opts), "onboarding-progress.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function now() {
|
|
33
|
+
return new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function freshMilestone() {
|
|
37
|
+
return {
|
|
38
|
+
status: "pending",
|
|
39
|
+
attempts: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function freshLedger() {
|
|
44
|
+
const at = now();
|
|
45
|
+
return {
|
|
46
|
+
schema_version: SCHEMA_VERSION,
|
|
47
|
+
started_at: at,
|
|
48
|
+
updated_at: at,
|
|
49
|
+
current_blocker: null,
|
|
50
|
+
milestones: Object.fromEntries(MILESTONES.map((id) => [id, freshMilestone()])),
|
|
51
|
+
doctor: {
|
|
52
|
+
latest: null,
|
|
53
|
+
runs: [],
|
|
54
|
+
},
|
|
55
|
+
events: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeLedger(value) {
|
|
60
|
+
const base = freshLedger();
|
|
61
|
+
if (!value || typeof value !== "object") return base;
|
|
62
|
+
const input = value;
|
|
63
|
+
base.started_at =
|
|
64
|
+
typeof input.started_at === "string" ? input.started_at : base.started_at;
|
|
65
|
+
base.updated_at =
|
|
66
|
+
typeof input.updated_at === "string" ? input.updated_at : base.updated_at;
|
|
67
|
+
base.current_blocker =
|
|
68
|
+
input.current_blocker && typeof input.current_blocker === "object"
|
|
69
|
+
? input.current_blocker
|
|
70
|
+
: null;
|
|
71
|
+
for (const id of MILESTONES) {
|
|
72
|
+
const old =
|
|
73
|
+
input.milestones && typeof input.milestones === "object"
|
|
74
|
+
? input.milestones[id]
|
|
75
|
+
: null;
|
|
76
|
+
if (!old || typeof old !== "object") continue;
|
|
77
|
+
base.milestones[id] = {
|
|
78
|
+
...freshMilestone(),
|
|
79
|
+
...old,
|
|
80
|
+
attempts: Number.isFinite(Number(old.attempts))
|
|
81
|
+
? Math.max(0, Number(old.attempts))
|
|
82
|
+
: 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (input.doctor && typeof input.doctor === "object") {
|
|
86
|
+
base.doctor.latest = input.doctor.latest || null;
|
|
87
|
+
base.doctor.runs = Array.isArray(input.doctor.runs)
|
|
88
|
+
? input.doctor.runs
|
|
89
|
+
: [];
|
|
90
|
+
}
|
|
91
|
+
base.events = Array.isArray(input.events) ? input.events : [];
|
|
92
|
+
return base;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readLedger(opts = {}) {
|
|
96
|
+
try {
|
|
97
|
+
const file = ledgerPath(opts);
|
|
98
|
+
if (!fs.existsSync(file)) return freshLedger();
|
|
99
|
+
return normalizeLedger(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
100
|
+
} catch {
|
|
101
|
+
return freshLedger();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeLedger(ledger, opts = {}) {
|
|
106
|
+
const dir = stateDir(opts);
|
|
107
|
+
const file = ledgerPath(opts);
|
|
108
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
ledger.schema_version = SCHEMA_VERSION;
|
|
110
|
+
ledger.updated_at = now();
|
|
111
|
+
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
112
|
+
fs.writeFileSync(tmp, JSON.stringify(ledger, null, 2) + "\n", {
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
mode: 0o600,
|
|
115
|
+
});
|
|
116
|
+
fs.renameSync(tmp, file);
|
|
117
|
+
try {
|
|
118
|
+
fs.chmodSync(file, 0o600);
|
|
119
|
+
} catch {
|
|
120
|
+
// Best effort on filesystems that do not expose POSIX permissions.
|
|
121
|
+
}
|
|
122
|
+
return ledger;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assertMilestone(id) {
|
|
126
|
+
if (!MILESTONES.includes(id)) {
|
|
127
|
+
throw new Error(`unknown onboarding milestone: ${id}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appendEvent(ledger, event) {
|
|
132
|
+
const milestone = ledger.milestones[event.milestone];
|
|
133
|
+
ledger.events.push({
|
|
134
|
+
event_id: crypto.randomUUID(),
|
|
135
|
+
occurred_at: now(),
|
|
136
|
+
milestone: event.milestone,
|
|
137
|
+
type: event.type,
|
|
138
|
+
attempt: milestone ? milestone.attempts : 0,
|
|
139
|
+
status: milestone ? milestone.status : undefined,
|
|
140
|
+
code: event.code,
|
|
141
|
+
metadata: event.metadata || {},
|
|
142
|
+
backend_sent_at: null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function mutate(opts, fn) {
|
|
147
|
+
const ledger = readLedger(opts);
|
|
148
|
+
fn(ledger);
|
|
149
|
+
return writeLedger(ledger, opts);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function recordAttempt(id, metadata = {}, opts = {}) {
|
|
153
|
+
assertMilestone(id);
|
|
154
|
+
return mutate(opts, (ledger) => {
|
|
155
|
+
const at = now();
|
|
156
|
+
const milestone = ledger.milestones[id];
|
|
157
|
+
milestone.attempts += 1;
|
|
158
|
+
milestone.last_attempt_at = at;
|
|
159
|
+
milestone.first_started_at = milestone.first_started_at || at;
|
|
160
|
+
if (milestone.status !== "complete") milestone.status = "in_progress";
|
|
161
|
+
if (ledger.current_blocker?.milestone === id) ledger.current_blocker = null;
|
|
162
|
+
appendEvent(ledger, { milestone: id, type: "attempt", metadata });
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function completeMilestone(id, metadata = {}, opts = {}) {
|
|
167
|
+
assertMilestone(id);
|
|
168
|
+
return mutate(opts, (ledger) => {
|
|
169
|
+
const milestone = ledger.milestones[id];
|
|
170
|
+
if (milestone.status === "complete") return;
|
|
171
|
+
const at = now();
|
|
172
|
+
milestone.status = "complete";
|
|
173
|
+
milestone.completed_at = at;
|
|
174
|
+
milestone.last_attempt_at = milestone.last_attempt_at || at;
|
|
175
|
+
milestone.first_started_at = milestone.first_started_at || at;
|
|
176
|
+
if (milestone.attempts === 0) milestone.attempts = 1;
|
|
177
|
+
if (ledger.current_blocker?.milestone === id) ledger.current_blocker = null;
|
|
178
|
+
appendEvent(ledger, { milestone: id, type: "completed", metadata });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function blockMilestone(id, code, message, metadata = {}, opts = {}) {
|
|
183
|
+
assertMilestone(id);
|
|
184
|
+
return mutate(opts, (ledger) => {
|
|
185
|
+
const at = now();
|
|
186
|
+
const milestone = ledger.milestones[id];
|
|
187
|
+
if (milestone.attempts === 0) milestone.attempts = 1;
|
|
188
|
+
milestone.status = "blocked";
|
|
189
|
+
milestone.last_attempt_at = at;
|
|
190
|
+
milestone.first_started_at = milestone.first_started_at || at;
|
|
191
|
+
milestone.last_error_code = code;
|
|
192
|
+
milestone.last_error = message;
|
|
193
|
+
ledger.current_blocker = {
|
|
194
|
+
milestone: id,
|
|
195
|
+
code,
|
|
196
|
+
message,
|
|
197
|
+
at,
|
|
198
|
+
attempt: milestone.attempts,
|
|
199
|
+
};
|
|
200
|
+
appendEvent(ledger, {
|
|
201
|
+
milestone: id,
|
|
202
|
+
type: "blocked",
|
|
203
|
+
code,
|
|
204
|
+
metadata,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function recordDoctorReport(report, opts = {}) {
|
|
210
|
+
return mutate(opts, (ledger) => {
|
|
211
|
+
const milestone = ledger.milestones.environment_checked;
|
|
212
|
+
const at = now();
|
|
213
|
+
const fullFailure =
|
|
214
|
+
report && report.phase === "full" && report.ok === false;
|
|
215
|
+
const preserveFullFailure =
|
|
216
|
+
report &&
|
|
217
|
+
report.phase === "pre_connect" &&
|
|
218
|
+
ledger.current_blocker?.milestone === "environment_checked" &&
|
|
219
|
+
ledger.current_blocker?.code === "doctor_full_failed";
|
|
220
|
+
milestone.attempts += 1;
|
|
221
|
+
milestone.last_attempt_at = at;
|
|
222
|
+
milestone.first_started_at = milestone.first_started_at || at;
|
|
223
|
+
milestone.status = fullFailure || preserveFullFailure ? "blocked" : "complete";
|
|
224
|
+
milestone.completed_at = fullFailure || preserveFullFailure
|
|
225
|
+
? milestone.completed_at
|
|
226
|
+
: milestone.completed_at || at;
|
|
227
|
+
milestone.last_doctor_ok = Boolean(report && report.ok);
|
|
228
|
+
milestone.last_doctor_phase = report && report.phase;
|
|
229
|
+
if (fullFailure) {
|
|
230
|
+
milestone.last_error_code = "doctor_full_failed";
|
|
231
|
+
milestone.last_error = `Full Doctor found ${report.summary?.fail ?? 1} failing check(s).`;
|
|
232
|
+
ledger.current_blocker = {
|
|
233
|
+
milestone: "environment_checked",
|
|
234
|
+
code: "doctor_full_failed",
|
|
235
|
+
message: milestone.last_error,
|
|
236
|
+
at,
|
|
237
|
+
attempt: milestone.attempts,
|
|
238
|
+
};
|
|
239
|
+
} else if (
|
|
240
|
+
!preserveFullFailure &&
|
|
241
|
+
ledger.current_blocker?.milestone === "environment_checked"
|
|
242
|
+
) {
|
|
243
|
+
ledger.current_blocker = null;
|
|
244
|
+
}
|
|
245
|
+
ledger.doctor.latest = report;
|
|
246
|
+
ledger.doctor.runs.push(report);
|
|
247
|
+
appendEvent(ledger, {
|
|
248
|
+
milestone: "environment_checked",
|
|
249
|
+
type: "doctor",
|
|
250
|
+
code: report && report.ok ? "doctor_ok" : "doctor_issues",
|
|
251
|
+
metadata: {
|
|
252
|
+
phase: report && report.phase,
|
|
253
|
+
ok: Boolean(report && report.ok),
|
|
254
|
+
summary: report && report.summary,
|
|
255
|
+
check_statuses: Object.fromEntries(
|
|
256
|
+
Array.isArray(report && report.checks)
|
|
257
|
+
? report.checks.map((check) => [check.id, check.status])
|
|
258
|
+
: []
|
|
259
|
+
),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function pendingBackendEvents(opts = {}) {
|
|
266
|
+
return readLedger(opts).events.filter((event) => !event.backend_sent_at);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function markBackendEventsSent(eventIds, opts = {}) {
|
|
270
|
+
const ids = new Set(eventIds || []);
|
|
271
|
+
if (ids.size === 0) return readLedger(opts);
|
|
272
|
+
return mutate(opts, (ledger) => {
|
|
273
|
+
const sentAt = now();
|
|
274
|
+
for (const event of ledger.events) {
|
|
275
|
+
if (ids.has(event.event_id)) event.backend_sent_at = sentAt;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function publicSnapshot(opts = {}) {
|
|
281
|
+
const ledger = readLedger(opts);
|
|
282
|
+
return {
|
|
283
|
+
schema_version: ledger.schema_version,
|
|
284
|
+
started_at: ledger.started_at,
|
|
285
|
+
updated_at: ledger.updated_at,
|
|
286
|
+
complete: MILESTONES.every(
|
|
287
|
+
(id) => ledger.milestones[id].status === "complete"
|
|
288
|
+
),
|
|
289
|
+
milestones: MILESTONES.map((id) => ({
|
|
290
|
+
id,
|
|
291
|
+
...ledger.milestones[id],
|
|
292
|
+
})),
|
|
293
|
+
current_blocker: ledger.current_blocker,
|
|
294
|
+
doctor: ledger.doctor.latest
|
|
295
|
+
? {
|
|
296
|
+
phase: ledger.doctor.latest.phase,
|
|
297
|
+
ok: ledger.doctor.latest.ok,
|
|
298
|
+
completed_at: ledger.doctor.latest.completed_at,
|
|
299
|
+
summary: ledger.doctor.latest.summary,
|
|
300
|
+
checks: (ledger.doctor.latest.checks || []).map((check) => ({
|
|
301
|
+
id: check.id,
|
|
302
|
+
name: check.name,
|
|
303
|
+
status: check.status,
|
|
304
|
+
detail: check.detail,
|
|
305
|
+
fix: check.fix,
|
|
306
|
+
})),
|
|
307
|
+
}
|
|
308
|
+
: null,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
MILESTONES,
|
|
314
|
+
blockMilestone,
|
|
315
|
+
completeMilestone,
|
|
316
|
+
ledgerPath,
|
|
317
|
+
markBackendEventsSent,
|
|
318
|
+
pendingBackendEvents,
|
|
319
|
+
publicSnapshot,
|
|
320
|
+
readLedger,
|
|
321
|
+
recordAttempt,
|
|
322
|
+
recordDoctorReport,
|
|
323
|
+
writeLedger,
|
|
324
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-autoposter",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.144",
|
|
4
4
|
"description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"social-autoposter": "bin/cli.js"
|
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
"mcp-servers/",
|
|
123
123
|
"mcp/dist/",
|
|
124
124
|
"!mcp/dist/pipeline.tgz",
|
|
125
|
+
"mcp/shared/",
|
|
125
126
|
"mcp/menubar/",
|
|
126
127
|
"mcp/package.json",
|
|
127
128
|
"mcp/manifest.json",
|
package/scripts/claude_job.py
CHANGED
|
@@ -164,6 +164,59 @@ DEFAULT_TIMEOUT_S = int(os.environ.get("SAPS_CLAUDE_QUEUE_TIMEOUT", "1800"))
|
|
|
164
164
|
# it would feed a stale prompt to a worker much later. Default 2x the timeout.
|
|
165
165
|
STALE_TTL_S = int(os.environ.get("SAPS_CLAUDE_QUEUE_STALE_TTL", str(DEFAULT_TIMEOUT_S * 2)))
|
|
166
166
|
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# EXPERIMENT (2026-06-29, per user): hide the "Top Posts by Project" few-shot
|
|
169
|
+
# block from the Phase-2b drafting prompt.
|
|
170
|
+
#
|
|
171
|
+
# WHY: that block is ~42% of the prompt — top_performers.py emits up to 5 curated
|
|
172
|
+
# example posts for EVERY project (~20 projects = ~74 examples, ~16k tokens), and
|
|
173
|
+
# it is NOT scoped to the projects in this cycle's candidates. On a `.mcpb` box the
|
|
174
|
+
# drafting runs inside a Claude Desktop scheduled-task session that the app
|
|
175
|
+
# SIGTERMs after ~120s; the 38k-token prompt pushed the worker past that window
|
|
176
|
+
# before it could submit, so jobs never drained. Dropping this block shrinks the
|
|
177
|
+
# prompt to ~22k tokens (one Read, faster turns) while KEEPING the "Best Example
|
|
178
|
+
# Per Style" block (which is style-scoped and tiny).
|
|
179
|
+
#
|
|
180
|
+
# This is a DELIVERY-LAYER trim only: the generator (top_performers.py, locked) is
|
|
181
|
+
# untouched — we strip the section from the prompt text right before it is queued.
|
|
182
|
+
# A loud marker is left in its place and a provider.log line is emitted, so it is
|
|
183
|
+
# obvious the section was intentionally hidden, not lost.
|
|
184
|
+
#
|
|
185
|
+
# DEFAULT: ON (hidden). Set SAPS_HIDE_TOP_BY_PROJECT=0 (or false/no) to restore the
|
|
186
|
+
# full per-project example block.
|
|
187
|
+
HIDE_TOP_BY_PROJECT = (
|
|
188
|
+
os.environ.get("SAPS_HIDE_TOP_BY_PROJECT", "1").strip().lower()
|
|
189
|
+
not in ("0", "false", "no", "off", "")
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _strip_top_by_project(prompt: str) -> str:
|
|
194
|
+
"""Remove the '### Top Posts by Project' block from a drafting prompt.
|
|
195
|
+
|
|
196
|
+
Returns the prompt with that one section replaced by a clearly-labelled
|
|
197
|
+
HIDDEN marker (so anyone reading the prompt sees it was intentionally hidden
|
|
198
|
+
behind SAPS_HIDE_TOP_BY_PROJECT, not silently dropped). No-op if the block is
|
|
199
|
+
absent (e.g. query prompts, or a report that produced no per-project posts).
|
|
200
|
+
The section runs from its '### Top Posts by Project' header to the next '##'/
|
|
201
|
+
'###' header (normally '### Bottom N Posts').
|
|
202
|
+
"""
|
|
203
|
+
start = "### Top Posts by Project"
|
|
204
|
+
i = prompt.find(start)
|
|
205
|
+
if i < 0:
|
|
206
|
+
return prompt
|
|
207
|
+
m = re.search(r"\n#{2,3} ", prompt[i + len(start):])
|
|
208
|
+
j = (i + len(start) + m.start() + 1) if m else len(prompt)
|
|
209
|
+
marker = (
|
|
210
|
+
"### Top Posts by Project — HIDDEN\n"
|
|
211
|
+
"[This per-project few-shot block (~16k tokens) was hidden at the delivery "
|
|
212
|
+
"layer by claude_job.py via SAPS_HIDE_TOP_BY_PROJECT (default ON, added "
|
|
213
|
+
"2026-06-29) so the drafting worker fits Claude Desktop's ~120s "
|
|
214
|
+
"scheduled-session window. Set SAPS_HIDE_TOP_BY_PROJECT=0 to restore it. "
|
|
215
|
+
"The 'Best Example Per Style' block above is kept.]\n\n"
|
|
216
|
+
)
|
|
217
|
+
return prompt[:i] + marker + prompt[j:]
|
|
218
|
+
|
|
219
|
+
|
|
167
220
|
|
|
168
221
|
# --------------------------------------------------------------------------- #
|
|
169
222
|
# Queue layout #
|
|
@@ -508,6 +561,19 @@ def cmd_provider(ns) -> int:
|
|
|
508
561
|
except Exception:
|
|
509
562
|
schema_text = None
|
|
510
563
|
|
|
564
|
+
# Delivery-layer trim: hide the "Top Posts by Project" block from drafting
|
|
565
|
+
# prompts so the worker fits the ~120s scheduled-session window. See
|
|
566
|
+
# HIDE_TOP_BY_PROJECT / _strip_top_by_project above. Drafting jobs only.
|
|
567
|
+
if qtype == "twitter-prep" and HIDE_TOP_BY_PROJECT:
|
|
568
|
+
_before_len = len(prompt)
|
|
569
|
+
prompt = _strip_top_by_project(prompt)
|
|
570
|
+
if len(prompt) != _before_len:
|
|
571
|
+
_plog(
|
|
572
|
+
"hid 'Top Posts by Project' block: -%d chars "
|
|
573
|
+
"(SAPS_HIDE_TOP_BY_PROJECT on; set =0 to restore)"
|
|
574
|
+
% (_before_len - len(prompt))
|
|
575
|
+
)
|
|
576
|
+
|
|
511
577
|
job_id = uuid.uuid4().hex
|
|
512
578
|
created = time.time()
|
|
513
579
|
_ensure_dirs(qtype)
|