social-autoposter 1.6.5 → 1.6.7
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/bin/cli.js +105 -0
- package/bin/server.js +99 -2
- package/package.json +2 -1
- package/scripts/_dm_icp_batch.sh +32 -0
- package/scripts/_dm_record_sent.sh +8 -0
- package/scripts/author_history_block.py +77 -1
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/engagement_styles.py +115 -0
- package/scripts/heartbeat.sh +51 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/insert_post_059.py +88 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/preflight.sh +304 -0
- package/scripts/run_claude.sh +398 -0
- package/scripts/send_batch_dms.sh +48 -0
- package/scripts/sync_ig_to_posts.py +11 -3
- package/scripts/test_installation_api.sh +52 -0
- package/skill/lib/twitter-backend.sh +32 -3
- package/skill/run-instagram-render.sh +49 -1
- package/skill/run-twitter-cycle.sh +1 -1
package/bin/cli.js
CHANGED
|
@@ -130,6 +130,81 @@ function installBrowserAgentConfigs() {
|
|
|
130
130
|
console.log(` browser profile dirs ready -> ${profilesDir}/{${BROWSER_PROFILES.join(',')}}`);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Detect whether we are running inside an AppMaker E2B VM. AppMaker provisions
|
|
134
|
+
// a Chromium on port 9222 behind the SOAX residential proxy at 127.0.0.1:3003,
|
|
135
|
+
// and that Chromium is the one the user logs into via the AppMaker UI (profile
|
|
136
|
+
// /root/.chromium-profile). The browser-harness Chrome on port 9555 with its
|
|
137
|
+
// own (logged-out, un-proxied) profile is wrong for this host, so we:
|
|
138
|
+
// 1. skip installBrowserHarness() entirely (saves disk + avoids a second
|
|
139
|
+
// headless Chrome ever spawning).
|
|
140
|
+
// 2. write ~/.social-autoposter-env so skill/lib/twitter-backend.sh sources
|
|
141
|
+
// TWITTER_CDP_URL=http://127.0.0.1:9222 instead of the default 9555.
|
|
142
|
+
// Detection: presence of /opt/startup.sh (the AppMaker bootstrap script that
|
|
143
|
+
// only exists on these VMs) AND a live HTTP response on 127.0.0.1:9222.
|
|
144
|
+
function isAppMakerVm() {
|
|
145
|
+
if (process.platform !== 'linux') return false;
|
|
146
|
+
if (!fs.existsSync('/opt/startup.sh')) return false;
|
|
147
|
+
// Probe Chromium DevTools on 9222. 2s timeout; if it answers, we're on AppMaker.
|
|
148
|
+
const probe = spawnSync('curl', ['-sf', '--max-time', '2', '-o', '/dev/null', 'http://127.0.0.1:9222/json/version'], { stdio: 'ignore' });
|
|
149
|
+
return probe.status === 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Write ~/.social-autoposter-env so skill/lib/twitter-backend.sh picks up the
|
|
153
|
+
// AppMaker-specific TWITTER_CDP_URL before its `${VAR:-default}` fallback hits.
|
|
154
|
+
// Idempotent: rewrites the file every invocation so a config edit on the VM
|
|
155
|
+
// can't drift away from what cli.js intends.
|
|
156
|
+
function writeAppMakerEnvFile() {
|
|
157
|
+
const envPath = path.join(HOME, '.social-autoposter-env');
|
|
158
|
+
const body = [
|
|
159
|
+
'# social-autoposter per-host env overrides',
|
|
160
|
+
'# Auto-generated by social-autoposter init/update on AppMaker E2B VMs.',
|
|
161
|
+
'# Edit by hand only if you know what you are doing; it gets rewritten on every update.',
|
|
162
|
+
'',
|
|
163
|
+
'# Point twitter pipeline at AppMaker\'s proxied Chromium (SOAX residential exit',
|
|
164
|
+
'# at 127.0.0.1:3003) instead of the harness Chrome on 9555. The Chromium on',
|
|
165
|
+
'# 9222 is the one the user logs into via the AppMaker UI.',
|
|
166
|
+
'export TWITTER_CDP_URL="http://127.0.0.1:9222"',
|
|
167
|
+
'',
|
|
168
|
+
].join('\n');
|
|
169
|
+
fs.writeFileSync(envPath, body);
|
|
170
|
+
console.log(` AppMaker VM detected -> wrote ${envPath} (TWITTER_CDP_URL=http://127.0.0.1:9222)`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// AppMaker VMs also need the twitter-harness MCP server (browser-harness/server.py)
|
|
174
|
+
// to drive port 9222, not its default 9555. That's a SECOND path the env file alone
|
|
175
|
+
// doesn't cover, because the MCP server is spawned by Claude as a subprocess with
|
|
176
|
+
// an env block taken from the MCP config file (--strict-mcp-config replaces the
|
|
177
|
+
// inherited env, so a parent BH_PORT export wouldn't reach it). So we patch the
|
|
178
|
+
// MCP config in-place to bake BH_PORT=9222 into its env block.
|
|
179
|
+
// Idempotent: parses the JSON, sets env.BH_PORT, rewrites. Safe to re-run.
|
|
180
|
+
function applyAppMakerMcpConfigOverrides() {
|
|
181
|
+
const cfgPath = path.join(HOME, '.claude', 'browser-agent-configs', 'twitter-harness-mcp.json');
|
|
182
|
+
if (!fs.existsSync(cfgPath)) {
|
|
183
|
+
console.log(` AppMaker MCP override: ${cfgPath} not found, skipping (will be picked up next run)`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
let cfg;
|
|
187
|
+
try {
|
|
188
|
+
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
189
|
+
} catch (e) {
|
|
190
|
+
console.warn(` AppMaker MCP override: failed to parse ${cfgPath}: ${e.message}`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const srv = cfg?.mcpServers?.['twitter-harness'];
|
|
194
|
+
if (!srv) {
|
|
195
|
+
console.warn(` AppMaker MCP override: ${cfgPath} has no mcpServers.twitter-harness entry`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!srv.env || typeof srv.env !== 'object') srv.env = {};
|
|
199
|
+
if (srv.env.BH_PORT === '9222') {
|
|
200
|
+
console.log(` AppMaker MCP override: BH_PORT=9222 already set in ${cfgPath}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
srv.env.BH_PORT = '9222';
|
|
204
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
205
|
+
console.log(` AppMaker MCP override: set BH_PORT=9222 in ${cfgPath}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
133
208
|
// Provision the browser-harness toolchain that backs the twitter-harness MCP:
|
|
134
209
|
// 1. install uv (Astral) if missing
|
|
135
210
|
// 2. git-clone browser-use/browser-harness
|
|
@@ -137,7 +212,31 @@ function installBrowserAgentConfigs() {
|
|
|
137
212
|
// 4. ensure `mcp` Python package is importable for server.py
|
|
138
213
|
// 5. copy our shipped server.py into ~/.claude/mcp-servers/browser-harness/
|
|
139
214
|
// All steps are idempotent.
|
|
215
|
+
//
|
|
216
|
+
// AppMaker VMs: the toolchain is STILL needed (the MCP server.py is what
|
|
217
|
+
// Claude invokes during Phase 1's tweet scan, and it requires uv + mcp +
|
|
218
|
+
// browser-harness CLI to run). The AppMaker-specific deltas are:
|
|
219
|
+
// (a) writeAppMakerEnvFile() points TWITTER_CDP_URL at 9222 for posting
|
|
220
|
+
// (b) applyAppMakerMcpConfigOverrides() injects BH_PORT=9222 so server.py
|
|
221
|
+
// drives the AppMaker Chromium instead of trying to launch its own
|
|
222
|
+
// Chrome on 9555. server.py's ensure_chrome() short-circuits when CDP
|
|
223
|
+
// is already alive on PORT, so no double-Chrome ever spawns.
|
|
224
|
+
// Previously we early-returned here on AppMaker, which left the VM without
|
|
225
|
+
// uv installed and broke Phase 1's Claude scan (the MCP server's `command:
|
|
226
|
+
// /root/.local/bin/uv` resolved to ENOENT, Claude got no tools, returned an
|
|
227
|
+
// empty envelope).
|
|
140
228
|
function installBrowserHarness() {
|
|
229
|
+
const onAppMaker = isAppMakerVm();
|
|
230
|
+
if (onAppMaker) {
|
|
231
|
+
console.log(' AppMaker VM detected -> installing harness toolchain (deps); MCP will be pointed at port 9222');
|
|
232
|
+
writeAppMakerEnvFile();
|
|
233
|
+
// scripts/run_claude.sh uses `uuidgen` for session IDs on AUP-retry. The
|
|
234
|
+
// base image ships libuuid1 (shared lib) but not the CLI tool — the
|
|
235
|
+
// package is `uuid-runtime`. Without it, run_claude.sh's session_id
|
|
236
|
+
// generation falls back to empty string and claude --session-id breaks.
|
|
237
|
+
console.log(' installing uuid-runtime (uuidgen) for run_claude.sh...');
|
|
238
|
+
spawnSync('bash', ['-lc', 'command -v uuidgen >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq uuid-runtime'], { stdio: 'inherit' });
|
|
239
|
+
}
|
|
141
240
|
console.log(' setting up browser-harness (twitter-harness MCP backend)...');
|
|
142
241
|
|
|
143
242
|
// Step 1: uv. Try the official installer first; fall back to pip.
|
|
@@ -394,6 +493,9 @@ function init() {
|
|
|
394
493
|
installBrowserHarness();
|
|
395
494
|
// Install browser agent MCP configs + profile dirs (skips existing files)
|
|
396
495
|
installBrowserAgentConfigs();
|
|
496
|
+
// On AppMaker VMs, patch the twitter-harness MCP config so its server.py
|
|
497
|
+
// drives port 9222 (AppMaker Chromium) instead of the default 9555.
|
|
498
|
+
if (isAppMakerVm()) applyAppMakerMcpConfigOverrides();
|
|
397
499
|
// Register those MCP servers with Claude so they show up in `claude mcp list`.
|
|
398
500
|
registerBrowserAgentMcpServers();
|
|
399
501
|
|
|
@@ -478,6 +580,9 @@ function update() {
|
|
|
478
580
|
installBrowserHarness();
|
|
479
581
|
// Top up browser agent configs (won't overwrite user customizations)
|
|
480
582
|
installBrowserAgentConfigs();
|
|
583
|
+
// On AppMaker VMs, patch the twitter-harness MCP config so its server.py
|
|
584
|
+
// drives port 9222 (AppMaker Chromium) instead of the default 9555.
|
|
585
|
+
if (isAppMakerVm()) applyAppMakerMcpConfigOverrides();
|
|
481
586
|
// Register any newly added MCP servers with Claude (idempotent).
|
|
482
587
|
registerBrowserAgentMcpServers();
|
|
483
588
|
|
package/bin/server.js
CHANGED
|
@@ -994,6 +994,74 @@ async function enrichEngageRuns(runs) {
|
|
|
994
994
|
}
|
|
995
995
|
}
|
|
996
996
|
|
|
997
|
+
// dm_outreach_* runs: skill/dm-outreach-*.sh always calls log_run.py with
|
|
998
|
+
// hardcoded --posted 0 --skipped 0 --failed 0 because per-DM outcomes go to
|
|
999
|
+
// the `dms` table via dm_db_update.py, not the log summary. Without an
|
|
1000
|
+
// enricher the dashboard literally renders "posted 0 / skipped 0" even on
|
|
1001
|
+
// runs that sent multiple DMs (and the operator wrongly concludes the cron
|
|
1002
|
+
// is broken). Mirror enrichEngageRuns: derive attempted / sent / skipped /
|
|
1003
|
+
// errored from `dms` rows whose claude_session_id belongs to this run's
|
|
1004
|
+
// window. Cost is preserved as-is (get_run_cost.py already aggregates the
|
|
1005
|
+
// whole job, identical pattern to engage / post-comments runs).
|
|
1006
|
+
async function enrichDMOutreachRuns(runs) {
|
|
1007
|
+
const dmRuns = runs.filter(r => r.job_type === 'dm-outreach' && r.platform_key);
|
|
1008
|
+
if (!dmRuns.length) return;
|
|
1009
|
+
let oldestMs = Infinity;
|
|
1010
|
+
for (const r of dmRuns) {
|
|
1011
|
+
const ms = new Date(r.started_at).getTime();
|
|
1012
|
+
if (ms < oldestMs) oldestMs = ms;
|
|
1013
|
+
}
|
|
1014
|
+
// Slack window: the shell wrapper sets CLAUDE_SESSION_ID up front but
|
|
1015
|
+
// claude_sessions row gets stamped when the headless run starts (a few
|
|
1016
|
+
// seconds in); skip rows can fire >5 min before sent rows in the same
|
|
1017
|
+
// session. 5 min slack matches what enrichRunsCostBreakdown uses.
|
|
1018
|
+
const slackMs = 5 * 60 * 1000;
|
|
1019
|
+
const since = new Date(oldestMs - slackMs).toISOString();
|
|
1020
|
+
// Join dms -> claude_sessions so each dms row carries the session's
|
|
1021
|
+
// started_at (used to assign it to the right run row when multiple
|
|
1022
|
+
// dm-outreach runs land in the same query window).
|
|
1023
|
+
const rows = await pq(
|
|
1024
|
+
"SELECT d.platform, d.status, cs.started_at, cs.script " +
|
|
1025
|
+
"FROM dms d JOIN claude_sessions cs ON cs.session_id = d.claude_session_id " +
|
|
1026
|
+
"WHERE cs.started_at >= $1::timestamp AND cs.script LIKE 'dm-outreach-%'",
|
|
1027
|
+
[since]
|
|
1028
|
+
);
|
|
1029
|
+
if (!rows) return;
|
|
1030
|
+
const pendingByPlatform = {};
|
|
1031
|
+
const pendingRows = await pq(
|
|
1032
|
+
"SELECT platform, COUNT(*)::int AS n FROM dms WHERE status='pending' GROUP BY platform"
|
|
1033
|
+
);
|
|
1034
|
+
if (pendingRows) {
|
|
1035
|
+
for (const r of pendingRows) pendingByPlatform[(r.platform || '').toLowerCase()] = r.n;
|
|
1036
|
+
}
|
|
1037
|
+
for (const run of dmRuns) {
|
|
1038
|
+
const startMs = new Date(run.started_at).getTime();
|
|
1039
|
+
const endMs = new Date(run.finished_at).getTime() + slackMs;
|
|
1040
|
+
// dm_outreach_twitter run -> DB platform 'x'. Reddit/LinkedIn keep slug.
|
|
1041
|
+
const dbPlatform = run.platform_key === 'twitter' ? 'x' : run.platform_key;
|
|
1042
|
+
let sent = 0, skipped = 0, errored = 0, attempted = 0;
|
|
1043
|
+
for (const p of rows) {
|
|
1044
|
+
if ((p.platform || '').toLowerCase() !== dbPlatform) continue;
|
|
1045
|
+
const sessTs = p.started_at instanceof Date ? p.started_at.getTime() : Date.parse(p.started_at);
|
|
1046
|
+
if (sessTs < startMs - slackMs || sessTs > endMs) continue;
|
|
1047
|
+
attempted++;
|
|
1048
|
+
if (p.status === 'sent') sent++;
|
|
1049
|
+
else if (p.status === 'skipped') skipped++;
|
|
1050
|
+
else if (p.status === 'error') errored++;
|
|
1051
|
+
}
|
|
1052
|
+
const prior = run.result || {};
|
|
1053
|
+
run.result = {
|
|
1054
|
+
type: 'dm-outreach',
|
|
1055
|
+
attempted,
|
|
1056
|
+
sent,
|
|
1057
|
+
skipped,
|
|
1058
|
+
errored,
|
|
1059
|
+
pending_now: pendingByPlatform[dbPlatform] || 0,
|
|
1060
|
+
cost_usd: prior.cost_usd || 0,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
997
1065
|
// scan_*_replies / scan_*_followups / scan_*_mentions runs: the shell wrappers
|
|
998
1066
|
// log `posted=FOUND` where FOUND is grepped from stdout, which is fragile and
|
|
999
1067
|
// zero-by-default. Replace it with a direct count of rows inserted into the
|
|
@@ -4237,6 +4305,7 @@ async function handleApi(req, res) {
|
|
|
4237
4305
|
}
|
|
4238
4306
|
await enrichLinkEditRuns(runs);
|
|
4239
4307
|
await enrichEngageRuns(runs);
|
|
4308
|
+
await enrichDMOutreachRuns(runs);
|
|
4240
4309
|
await enrichCheckRepliesRuns(runs);
|
|
4241
4310
|
await enrichPostCommentsLinkedInRuns(runs);
|
|
4242
4311
|
await enrichPostCommentsTwitterRuns(runs);
|
|
@@ -6165,7 +6234,7 @@ async function handleApi(req, res) {
|
|
|
6165
6234
|
const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
|
|
6166
6235
|
const windowHours = WINDOW_HOURS[windowKey];
|
|
6167
6236
|
const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
|
|
6168
|
-
const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github']);
|
|
6237
|
+
const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github', 'instagram']);
|
|
6169
6238
|
const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
|
|
6170
6239
|
const pc = auth.projectClause(req.user, 'pl.project_name', url.searchParams.get('project'));
|
|
6171
6240
|
if (!pc.ok) return json(res, { destinations: [], window: windowKey, platform: 'all' });
|
|
@@ -6291,7 +6360,7 @@ async function handleApi(req, res) {
|
|
|
6291
6360
|
const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
|
|
6292
6361
|
const windowHours = WINDOW_HOURS[windowKey];
|
|
6293
6362
|
const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
|
|
6294
|
-
const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github']);
|
|
6363
|
+
const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github', 'instagram']);
|
|
6295
6364
|
const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
|
|
6296
6365
|
const pc = auth.projectClause(req.user, 'pl.project_name', url.searchParams.get('project'));
|
|
6297
6366
|
if (!pc.ok) return json(res, { links: [], window: windowKey, platform: 'all' });
|
|
@@ -6783,6 +6852,9 @@ async function handleApi(req, res) {
|
|
|
6783
6852
|
linkedin: p => !isDisabled(p, 'linkedin') && hasSearchTopics(p),
|
|
6784
6853
|
reddit: p => !isDisabled(p, 'reddit'),
|
|
6785
6854
|
moltbook: p => !isDisabled(p, 'moltbook'),
|
|
6855
|
+
// Instagram doesn't use search_topics; posts come from prerendered mixer
|
|
6856
|
+
// drafts. Projects can still opt out via platforms_disabled.
|
|
6857
|
+
instagram: p => !isDisabled(p, 'instagram'),
|
|
6786
6858
|
};
|
|
6787
6859
|
const totalWeightByPlatform = {};
|
|
6788
6860
|
for (const plat of platforms) {
|
|
@@ -9487,6 +9559,30 @@ function renderResult(run) {
|
|
|
9487
9559
|
'</span>'
|
|
9488
9560
|
);
|
|
9489
9561
|
}
|
|
9562
|
+
if (r.type === 'dm-outreach') {
|
|
9563
|
+
// Per-run DB-derived counts (see enrichDMOutreachRuns in server.js).
|
|
9564
|
+
// dm-outreach-*.sh hardcodes posted/skipped/failed=0 in log_run.py
|
|
9565
|
+
// because per-DM outcomes go to the dms table via dm_db_update.py.
|
|
9566
|
+
// Pills here come from joining dms -> claude_sessions on the run window.
|
|
9567
|
+
// attempted is the total candidates the run actually resolved (sent +
|
|
9568
|
+
// skipped + errored); the operator wants to see "we tried 7, sent 2"
|
|
9569
|
+
// not "0". (No backticks in this comment block: renderResult lives
|
|
9570
|
+
// inside an outer HTML template literal, see note at the engage branch.)
|
|
9571
|
+
const attempted = r.attempted || 0;
|
|
9572
|
+
const sent = r.sent || 0;
|
|
9573
|
+
const skipped = r.skipped || 0;
|
|
9574
|
+
const errored = r.errored || 0;
|
|
9575
|
+
const pending = r.pending_now || 0;
|
|
9576
|
+
const cost = r.cost_usd || 0;
|
|
9577
|
+
return (
|
|
9578
|
+
pill('attempted', attempted, attempted > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
9579
|
+
pill('sent', sent, sent > 0 ? '#22c55e' : 'var(--muted)') +
|
|
9580
|
+
pill('skipped', skipped, skipped > 0 ? '#eab308' : 'var(--muted)') +
|
|
9581
|
+
pill('errored', errored, errored > 0 ? '#ef4444' : 'var(--muted)') +
|
|
9582
|
+
pill('queue', pending, pending > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
9583
|
+
'<span style="font-size:12px;color:var(--muted);">$' + cost.toFixed(2) + '</span>'
|
|
9584
|
+
);
|
|
9585
|
+
}
|
|
9490
9586
|
if (r.type === 'engage') {
|
|
9491
9587
|
// Per-run DB-derived counts (see enrichEngageRuns in server.js).
|
|
9492
9588
|
// Empty-queue runs show "queue empty" so the operator can tell a
|
|
@@ -16560,6 +16656,7 @@ const PROJECT_STATUS_SORT_FIELDS = {
|
|
|
16560
16656
|
linkedin: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.linkedin) || 0 },
|
|
16561
16657
|
moltbook: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.moltbook) || 0 },
|
|
16562
16658
|
github: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.github) || 0 },
|
|
16659
|
+
instagram: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.instagram) || 0 },
|
|
16563
16660
|
};
|
|
16564
16661
|
function _sortProjectRows(rows) {
|
|
16565
16662
|
const { field, dir } = _projectStatusSort;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-autoposter",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.7",
|
|
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"
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
|
15
15
|
"scripts/*.py",
|
|
16
|
+
"scripts/*.sh",
|
|
16
17
|
"scripts/engagement_styles_extra.json",
|
|
17
18
|
"schema-postgres.sql",
|
|
18
19
|
"config.example.json",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Usage: _dm_icp_batch.sh DM_ID DEFAULT_LABEL DEFAULT_NOTES [override:project=label:notes ...]
|
|
3
|
+
# Sets default label/notes on all 19 projects, then applies overrides
|
|
4
|
+
set -e
|
|
5
|
+
DM_ID=$1
|
|
6
|
+
DEFAULT_LABEL=$2
|
|
7
|
+
DEFAULT_NOTES=$3
|
|
8
|
+
shift 3
|
|
9
|
+
|
|
10
|
+
declare -A LABELS NOTES
|
|
11
|
+
PROJECTS=(fazm Terminator "macOS MCP" Vipassana S4L "AI Browser Profile" "WhatsApp MCP" "macOS Session Replay" Cyrano Assrt PieLine Clone mk0r fde10x claude-meter c0nsl tenxats paperback-expert studyly)
|
|
12
|
+
for p in "${PROJECTS[@]}"; do
|
|
13
|
+
LABELS["$p"]="$DEFAULT_LABEL"
|
|
14
|
+
NOTES["$p"]="$DEFAULT_NOTES"
|
|
15
|
+
done
|
|
16
|
+
|
|
17
|
+
for arg in "$@"; do
|
|
18
|
+
proj="${arg%%=*}"
|
|
19
|
+
rest="${arg#*=}"
|
|
20
|
+
lbl="${rest%%:*}"
|
|
21
|
+
note="${rest#*:}"
|
|
22
|
+
LABELS["$proj"]="$lbl"
|
|
23
|
+
NOTES["$proj"]="$note"
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
cd /Users/matthewdi/social-autoposter
|
|
27
|
+
for p in "${PROJECTS[@]}"; do
|
|
28
|
+
python3 scripts/dm_conversation.py set-icp-precheck \
|
|
29
|
+
--dm-id "$DM_ID" --project "$p" \
|
|
30
|
+
--label "${LABELS[$p]}" --notes "${NOTES[$p]}" >/dev/null
|
|
31
|
+
done
|
|
32
|
+
echo "ICP prechecks set for DM $DM_ID (default=$DEFAULT_LABEL, ${#@} overrides)"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Usage: ./_dm_record_sent.sh DM_ID "content"
|
|
3
|
+
set -e
|
|
4
|
+
DM=$1
|
|
5
|
+
CONTENT=$2
|
|
6
|
+
source ~/social-autoposter/.env
|
|
7
|
+
psql "$DATABASE_URL" -c "UPDATE dms SET status='sent', our_dm_content=\$\$$CONTENT\$\$, sent_at=NOW(), claude_session_id='967dfa68-2d39-42de-99e9-acdb2d3552ac'::uuid WHERE id=$DM;" 2>&1 | tail -1
|
|
8
|
+
python3 /Users/matthewdi/social-autoposter/scripts/dm_conversation.py log-outbound --dm-id "$DM" --content "$CONTENT" --verified 2>&1 | tail -1
|
|
@@ -49,6 +49,73 @@ PLATFORM_ALIAS = {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
# Per-process cache for active campaign suffixes; populated lazily on first
|
|
53
|
+
# format_block() call. None = not loaded yet; [] = loaded but empty.
|
|
54
|
+
_ACTIVE_CAMPAIGN_SUFFIXES_CACHE = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_active_campaign_suffixes():
|
|
58
|
+
"""Best-effort: return a list of currently-active campaign suffix literals.
|
|
59
|
+
|
|
60
|
+
Mirrors the helper of the same name in scripts/top_performers.py. We
|
|
61
|
+
duplicate (rather than import) to keep this module's failure mode
|
|
62
|
+
independent of top_performers' larger dependency surface.
|
|
63
|
+
|
|
64
|
+
Used to strip the suffix from `our_content` before injecting prior
|
|
65
|
+
interactions into the draft prompt, so the LLM never learns to echo
|
|
66
|
+
the suffix in its drafts (which would then double-fire when the
|
|
67
|
+
tool-layer injection at twitter_browser.reply_to_tweet / reddit_browser
|
|
68
|
+
appends a second copy). See feedback_suffix_injection_gating.md for the
|
|
69
|
+
history; this closes the 4th leak path that the 2026-05-19 sweep missed.
|
|
70
|
+
|
|
71
|
+
On any failure returns []: missing strip is preferable to crashing the
|
|
72
|
+
prompt assembly path.
|
|
73
|
+
"""
|
|
74
|
+
global _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
|
|
75
|
+
if _ACTIVE_CAMPAIGN_SUFFIXES_CACHE is not None:
|
|
76
|
+
return _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
|
|
77
|
+
suffixes = []
|
|
78
|
+
try:
|
|
79
|
+
from http_api import api_get # noqa: E402
|
|
80
|
+
resp = api_get(
|
|
81
|
+
"/api/v1/campaigns",
|
|
82
|
+
query={"status": "active", "has_suffix": "true", "limit": 500},
|
|
83
|
+
)
|
|
84
|
+
rows = ((resp or {}).get("data") or {}).get("campaigns") or []
|
|
85
|
+
for r in rows:
|
|
86
|
+
s = (r.get("suffix") or "").strip()
|
|
87
|
+
if s and s not in suffixes:
|
|
88
|
+
suffixes.append(s)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(
|
|
91
|
+
f"[author_history_block] _load_active_campaign_suffixes failed: {e!r}",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
_ACTIVE_CAMPAIGN_SUFFIXES_CACHE = suffixes
|
|
95
|
+
return suffixes
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _strip_active_campaign_suffixes(text, suffixes):
|
|
99
|
+
"""Trailing-only, idempotent strip of any active-campaign suffix.
|
|
100
|
+
|
|
101
|
+
Identical contract to top_performers._strip_active_campaign_suffixes.
|
|
102
|
+
Idempotent loop also collapses an already-doubled historical suffix
|
|
103
|
+
(e.g. "... written with s4lai written with s4lai") to clean text.
|
|
104
|
+
Trailing-only so we never touch the body of the comment.
|
|
105
|
+
"""
|
|
106
|
+
if not text or not suffixes:
|
|
107
|
+
return text
|
|
108
|
+
cleaned = text.rstrip()
|
|
109
|
+
changed = True
|
|
110
|
+
while changed:
|
|
111
|
+
changed = False
|
|
112
|
+
for sfx in suffixes:
|
|
113
|
+
if sfx and cleaned.endswith(sfx):
|
|
114
|
+
cleaned = cleaned[: -len(sfx)].rstrip()
|
|
115
|
+
changed = True
|
|
116
|
+
return cleaned
|
|
117
|
+
|
|
118
|
+
|
|
52
119
|
def _normalize(handle):
|
|
53
120
|
"""Lowercase + strip @, u/, / prefixes. Empty/'unknown' → empty string."""
|
|
54
121
|
if not handle:
|
|
@@ -188,6 +255,12 @@ def format_block(rows, author, platform, days):
|
|
|
188
255
|
f"window={days}d, latest first):"
|
|
189
256
|
)
|
|
190
257
|
lines = [header]
|
|
258
|
+
# Load active campaign suffixes ONCE per format_block call so we strip
|
|
259
|
+
# them off `our_content` BEFORE truncation. Short Twitter replies
|
|
260
|
+
# (≤140 chars total) would otherwise show the suffix verbatim in the
|
|
261
|
+
# exemplar, training the LLM to echo it; the tool layer then appends
|
|
262
|
+
# a second copy. See feedback_suffix_injection_gating.md.
|
|
263
|
+
suffix_strip_list = _load_active_campaign_suffixes()
|
|
191
264
|
for row in rows:
|
|
192
265
|
(
|
|
193
266
|
_id,
|
|
@@ -202,7 +275,10 @@ def format_block(rows, author, platform, days):
|
|
|
202
275
|
) = row
|
|
203
276
|
date = posted_at.date().isoformat() if posted_at else "?"
|
|
204
277
|
proj = project or "?"
|
|
205
|
-
|
|
278
|
+
our_content_clean = _strip_active_campaign_suffixes(
|
|
279
|
+
our_content, suffix_strip_list
|
|
280
|
+
)
|
|
281
|
+
ours = _truncate(our_content_clean, 140)
|
|
206
282
|
eng_bits = []
|
|
207
283
|
if upvotes:
|
|
208
284
|
eng_bits.append(f"likes={upvotes}")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# bench_dashboard.sh
|
|
3
|
+
#
|
|
4
|
+
# Benchmark Social Autoposter dashboard endpoint latencies using curl + awk.
|
|
5
|
+
# Prints a table with p50/p95/min/max per endpoint.
|
|
6
|
+
#
|
|
7
|
+
# Env vars:
|
|
8
|
+
# BASE_URL default http://localhost:3141
|
|
9
|
+
# RUNS default 10 (requests per endpoint)
|
|
10
|
+
# CONCURRENCY default 1 (serial). If >1, uses background curls + wait.
|
|
11
|
+
#
|
|
12
|
+
# Always exits 0.
|
|
13
|
+
|
|
14
|
+
set -u
|
|
15
|
+
|
|
16
|
+
BASE_URL="${BASE_URL:-http://localhost:3141}"
|
|
17
|
+
RUNS="${RUNS:-10}"
|
|
18
|
+
CONCURRENCY="${CONCURRENCY:-1}"
|
|
19
|
+
|
|
20
|
+
ENDPOINTS=(
|
|
21
|
+
"/"
|
|
22
|
+
"/api/pending"
|
|
23
|
+
"/api/activity/stats?hours=24"
|
|
24
|
+
"/api/style/stats?hours=24"
|
|
25
|
+
"/api/status"
|
|
26
|
+
"/api/jobs"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
30
|
+
|
|
31
|
+
printf 'bench_dashboard.sh time=%s base=%s runs=%s concurrency=%s\n' \
|
|
32
|
+
"$TIMESTAMP" "$BASE_URL" "$RUNS" "$CONCURRENCY"
|
|
33
|
+
printf '\n'
|
|
34
|
+
|
|
35
|
+
# Header row
|
|
36
|
+
printf '%-40s %-3s %-7s %-7s %-7s %-7s %s\n' \
|
|
37
|
+
"endpoint" "n" "p50" "p95" "min" "max" "codes"
|
|
38
|
+
|
|
39
|
+
TMPDIR="$(mktemp -d -t bench_dashboard.XXXXXX)"
|
|
40
|
+
trap 'rm -rf "$TMPDIR"' EXIT
|
|
41
|
+
|
|
42
|
+
# Run one curl, append "http_code time_total" to the given outfile.
|
|
43
|
+
# Args: URL OUTFILE
|
|
44
|
+
run_one() {
|
|
45
|
+
local url="$1"
|
|
46
|
+
local out="$2"
|
|
47
|
+
# -s silent, -o /dev/null discard body, -w format.
|
|
48
|
+
# On connection failure curl prints "000 0.000".
|
|
49
|
+
local line
|
|
50
|
+
line="$(curl -s -o /dev/null -w '%{http_code} %{time_total}\n' \
|
|
51
|
+
--max-time 60 "$url" 2>/dev/null || true)"
|
|
52
|
+
if [ -z "$line" ]; then
|
|
53
|
+
line="000 0.000"
|
|
54
|
+
fi
|
|
55
|
+
printf '%s\n' "$line" >> "$out"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for ep in "${ENDPOINTS[@]}"; do
|
|
59
|
+
url="${BASE_URL}${ep}"
|
|
60
|
+
outfile="${TMPDIR}/out.$$.$(echo "$ep" | tr -c 'A-Za-z0-9' '_')"
|
|
61
|
+
: > "$outfile"
|
|
62
|
+
|
|
63
|
+
if [ "$CONCURRENCY" -le 1 ]; then
|
|
64
|
+
i=0
|
|
65
|
+
while [ "$i" -lt "$RUNS" ]; do
|
|
66
|
+
run_one "$url" "$outfile"
|
|
67
|
+
i=$((i + 1))
|
|
68
|
+
done
|
|
69
|
+
else
|
|
70
|
+
# Launch in waves of CONCURRENCY until RUNS total are done.
|
|
71
|
+
launched=0
|
|
72
|
+
while [ "$launched" -lt "$RUNS" ]; do
|
|
73
|
+
wave=0
|
|
74
|
+
pids=""
|
|
75
|
+
while [ "$wave" -lt "$CONCURRENCY" ] && [ "$launched" -lt "$RUNS" ]; do
|
|
76
|
+
run_one "$url" "$outfile" &
|
|
77
|
+
pids="$pids $!"
|
|
78
|
+
wave=$((wave + 1))
|
|
79
|
+
launched=$((launched + 1))
|
|
80
|
+
done
|
|
81
|
+
# wait for this wave
|
|
82
|
+
for p in $pids; do
|
|
83
|
+
wait "$p" 2>/dev/null || true
|
|
84
|
+
done
|
|
85
|
+
done
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Compute stats with awk.
|
|
89
|
+
# Input: lines of "CODE TIME". Output one line:
|
|
90
|
+
# count p50 p95 min max codes
|
|
91
|
+
awk -v ep="$ep" '
|
|
92
|
+
{
|
|
93
|
+
code = $1
|
|
94
|
+
t = $2 + 0
|
|
95
|
+
times[NR] = t
|
|
96
|
+
codes[code]++
|
|
97
|
+
n++
|
|
98
|
+
if (n == 1 || t < mn) mn = t
|
|
99
|
+
if (n == 1 || t > mx) mx = t
|
|
100
|
+
}
|
|
101
|
+
END {
|
|
102
|
+
if (n == 0) {
|
|
103
|
+
printf "%-40s %-3d %-7s %-7s %-7s %-7s %s\n", ep, 0, "-", "-", "-", "-", "none"
|
|
104
|
+
exit
|
|
105
|
+
}
|
|
106
|
+
# sort times ascending (insertion sort, fine for small n)
|
|
107
|
+
for (i = 2; i <= n; i++) {
|
|
108
|
+
v = times[i]; j = i - 1
|
|
109
|
+
while (j >= 1 && times[j] > v) { times[j+1] = times[j]; j-- }
|
|
110
|
+
times[j+1] = v
|
|
111
|
+
}
|
|
112
|
+
# p50 and p95 using nearest-rank, 1-indexed
|
|
113
|
+
p50_idx = int((50/100) * n + 0.9999); if (p50_idx < 1) p50_idx = 1; if (p50_idx > n) p50_idx = n
|
|
114
|
+
p95_idx = int((95/100) * n + 0.9999); if (p95_idx < 1) p95_idx = 1; if (p95_idx > n) p95_idx = n
|
|
115
|
+
p50 = times[p50_idx]
|
|
116
|
+
p95 = times[p95_idx]
|
|
117
|
+
|
|
118
|
+
# Build codes string sorted by code key
|
|
119
|
+
ncodes = 0
|
|
120
|
+
for (c in codes) { ncodes++; ck[ncodes] = c }
|
|
121
|
+
for (i = 2; i <= ncodes; i++) {
|
|
122
|
+
v = ck[i]; j = i - 1
|
|
123
|
+
while (j >= 1 && ck[j] > v) { ck[j+1] = ck[j]; j-- }
|
|
124
|
+
ck[j+1] = v
|
|
125
|
+
}
|
|
126
|
+
codes_str = ""
|
|
127
|
+
for (i = 1; i <= ncodes; i++) {
|
|
128
|
+
sep = (i == 1) ? "" : " "
|
|
129
|
+
codes_str = codes_str sep ck[i] "x" codes[ck[i]]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
printf "%-40s %-3d %-7.3f %-7.3f %-7.3f %-7.3f %s\n", \
|
|
133
|
+
ep, n, p50, p95, mn, mx, codes_str
|
|
134
|
+
}
|
|
135
|
+
' "$outfile"
|
|
136
|
+
done
|
|
137
|
+
|
|
138
|
+
exit 0
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# clean_stale_singleton.sh — Remove stale Chrome singleton symlinks from a
|
|
3
|
+
# browser profile if (and only if) the PID they reference is dead.
|
|
4
|
+
#
|
|
5
|
+
# Background: when Chrome exits ungracefully (SIGKILL, system sleep, force
|
|
6
|
+
# quit, jetsam), it leaves Singleton{Lock,Cookie,Socket} + RunningChromeVersion
|
|
7
|
+
# symlinks behind. On the next launch Chrome sees them, fails to talk to the
|
|
8
|
+
# (now-dead) PID listed in SingletonLock, and pops "Something went wrong when
|
|
9
|
+
# opening your profile. Some features may be unavailable" once per service
|
|
10
|
+
# (cookies, prefs, history, sync, ...) — typically 7 dialogs. Until the user
|
|
11
|
+
# clicks all of them, no pages load and the pipeline hangs.
|
|
12
|
+
#
|
|
13
|
+
# Safe to call before any Chrome launch on the same profile. Idempotent.
|
|
14
|
+
# Refuses to clean if the SingletonLock PID is still alive (so we never
|
|
15
|
+
# yank locks out from under a running Chrome — including a real user
|
|
16
|
+
# session attached to the same profile).
|
|
17
|
+
#
|
|
18
|
+
# Usage: clean_stale_singleton.sh <profile_dir>
|
|
19
|
+
# e.g. clean_stale_singleton.sh ~/.claude/browser-profiles/twitter
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
profile_dir="${1:-}"
|
|
24
|
+
if [ -z "$profile_dir" ] || [ ! -d "$profile_dir" ]; then
|
|
25
|
+
echo "[clean_stale_singleton] usage: $0 <profile_dir>" >&2
|
|
26
|
+
exit 0 # never block the pipeline on a misuse; just no-op
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
lock_link="$profile_dir/SingletonLock"
|
|
30
|
+
|
|
31
|
+
# No lock = nothing to clean.
|
|
32
|
+
if [ ! -L "$lock_link" ] && [ ! -e "$lock_link" ]; then
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# SingletonLock target format: <hostname>-<pid>
|
|
37
|
+
target=$(readlink "$lock_link" 2>/dev/null || echo "")
|
|
38
|
+
pid="${target##*-}"
|
|
39
|
+
|
|
40
|
+
if [ -n "$pid" ] && [[ "$pid" =~ ^[0-9]+$ ]]; then
|
|
41
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
42
|
+
# Live Chrome owns this profile. Do NOT touch.
|
|
43
|
+
echo "[clean_stale_singleton] ${profile_dir##*/}: SingletonLock PID $pid alive; leaving locks intact." >&2
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Stale: PID dead, malformed, or unreadable. Nuke the singletons so Chrome
|
|
49
|
+
# can launch cleanly. Also drop RunningChromeVersion which Chrome cross-checks.
|
|
50
|
+
rm -f "$profile_dir/SingletonLock" \
|
|
51
|
+
"$profile_dir/SingletonCookie" \
|
|
52
|
+
"$profile_dir/SingletonSocket" \
|
|
53
|
+
"$profile_dir/RunningChromeVersion"
|
|
54
|
+
|
|
55
|
+
echo "[clean_stale_singleton] ${profile_dir##*/}: cleared stale singleton locks (was PID ${pid:-unknown})." >&2
|
|
56
|
+
exit 0
|