social-autoposter 1.6.6 → 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 +64 -5
- package/bin/server.js +96 -0
- 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/run-instagram-render.sh +49 -1
- package/skill/run-twitter-cycle.sh +1 -1
package/bin/cli.js
CHANGED
|
@@ -170,19 +170,72 @@ function writeAppMakerEnvFile() {
|
|
|
170
170
|
console.log(` AppMaker VM detected -> wrote ${envPath} (TWITTER_CDP_URL=http://127.0.0.1:9222)`);
|
|
171
171
|
}
|
|
172
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
|
+
|
|
173
208
|
// Provision the browser-harness toolchain that backs the twitter-harness MCP:
|
|
174
209
|
// 1. install uv (Astral) if missing
|
|
175
210
|
// 2. git-clone browser-use/browser-harness
|
|
176
211
|
// 3. uv tool install -e . (provides the `browser-harness` CLI)
|
|
177
212
|
// 4. ensure `mcp` Python package is importable for server.py
|
|
178
213
|
// 5. copy our shipped server.py into ~/.claude/mcp-servers/browser-harness/
|
|
179
|
-
// All steps are idempotent.
|
|
180
|
-
//
|
|
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).
|
|
181
228
|
function installBrowserHarness() {
|
|
182
|
-
|
|
183
|
-
|
|
229
|
+
const onAppMaker = isAppMakerVm();
|
|
230
|
+
if (onAppMaker) {
|
|
231
|
+
console.log(' AppMaker VM detected -> installing harness toolchain (deps); MCP will be pointed at port 9222');
|
|
184
232
|
writeAppMakerEnvFile();
|
|
185
|
-
|
|
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' });
|
|
186
239
|
}
|
|
187
240
|
console.log(' setting up browser-harness (twitter-harness MCP backend)...');
|
|
188
241
|
|
|
@@ -440,6 +493,9 @@ function init() {
|
|
|
440
493
|
installBrowserHarness();
|
|
441
494
|
// Install browser agent MCP configs + profile dirs (skips existing files)
|
|
442
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();
|
|
443
499
|
// Register those MCP servers with Claude so they show up in `claude mcp list`.
|
|
444
500
|
registerBrowserAgentMcpServers();
|
|
445
501
|
|
|
@@ -524,6 +580,9 @@ function update() {
|
|
|
524
580
|
installBrowserHarness();
|
|
525
581
|
// Top up browser agent configs (won't overwrite user customizations)
|
|
526
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();
|
|
527
586
|
// Register any newly added MCP servers with Claude (idempotent).
|
|
528
587
|
registerBrowserAgentMcpServers();
|
|
529
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);
|
|
@@ -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
|
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
|