social-autoposter 1.6.13 → 1.6.14

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 CHANGED
@@ -153,20 +153,20 @@ function isAppMakerVm() {
153
153
  // AppMaker-specific TWITTER_CDP_URL before its `${VAR:-default}` fallback hits.
154
154
  // Idempotent: rewrites the file every invocation so a config edit on the VM
155
155
  // can't drift away from what cli.js intends.
156
- function writeAppMakerEnvFile() {
156
+ function writeAppMakerEnvFile(handleFromDb) {
157
157
  const envPath = path.join(HOME, '.social-autoposter-env');
158
- // Preserve a previously-set AUTOPOSTER_TWITTER_HANDLE across rewrites. This
159
- // is per-VM state (which @handle this sandbox posts as) and is NOT something
160
- // the generic bootstrap knows the operator sets it once after login, and
161
- // the cycle + restore_twitter_session.py read it via twitter_account.resolve_handle().
162
- // The sandbox's config.json (which also carries the handle) gets reseeded
163
- // from /etc/skel-root on substitution, so the env var is the durable home.
164
- let preservedHandle = '';
165
- try {
166
- const prev = fs.readFileSync(envPath, 'utf8');
167
- const m = prev.match(/^\s*export\s+AUTOPOSTER_TWITTER_HANDLE=(.+)\s*$/m);
168
- if (m) preservedHandle = m[1].trim();
169
- } catch { /* no prior file */ }
158
+ // Source of truth for the handle is the DB (social_accounts.handle keyed by
159
+ // vm_session_key). bootstrap-vm passes it in. Fallback: preserve a previously
160
+ // set value across rewrites if no DB-sourced handle was provided (matters
161
+ // when this runs from `social-autoposter update` without a fresh DB fetch).
162
+ let preservedHandle = String(handleFromDb || '').trim().replace(/^@/, '');
163
+ if (!preservedHandle) {
164
+ try {
165
+ const prev = fs.readFileSync(envPath, 'utf8');
166
+ const m = prev.match(/^\s*export\s+AUTOPOSTER_TWITTER_HANDLE=(.+)\s*$/m);
167
+ if (m) preservedHandle = m[1].trim();
168
+ } catch { /* no prior file */ }
169
+ }
170
170
 
171
171
  const lines = [
172
172
  '# social-autoposter per-host env overrides',
@@ -290,6 +290,115 @@ function applyAppMakerMcpConfigOverrides() {
290
290
  // uv installed and broke Phase 1's Claude scan (the MCP server's `command:
291
291
  // /root/.local/bin/uv` resolved to ENOENT, Claude got no tools, returned an
292
292
  // empty envelope).
293
+ // AppMaker VM self-bootstrap. Single entry point that the appmaker template
294
+ // startup.sh calls on every fresh sandbox boot. Reads the stable sessionKey
295
+ // from /run/mk0r-session.json (which the appmaker bridge rewrites on every
296
+ // session bind, and which survives E2B sandbox substitution — only the
297
+ // sandboxId changes), then asks the social-autoposter HTTP API which Twitter
298
+ // account this VM is bound to (handle + stored login cookies, keyed by
299
+ // social_accounts.vm_session_key). With that single DB answer it sets up
300
+ // everything: env file (with the DB-sourced handle), profile symlink, MCP
301
+ // config (BH_PORT=9222), uuid-runtime, then restores the Twitter login by
302
+ // re-injecting the stored cookies via CDP.
303
+ //
304
+ // This is the "one proper fix" for sandbox substitution: the VM holds no
305
+ // per-VM state on disk — the DB does, keyed by the stable sessionKey. So
306
+ // any fresh sandbox can rebuild itself by reading /run/mk0r-session.json
307
+ // and calling one route.
308
+ function bootstrapVm() {
309
+ if (!isAppMakerVm()) {
310
+ console.error('bootstrap-vm: not an AppMaker VM (no /opt/startup.sh + CDP :9222). Use `init` or `update` on dev boxes.');
311
+ process.exit(2);
312
+ }
313
+ console.log(' AppMaker VM bootstrap: resolving identity from DB by sessionKey...');
314
+
315
+ let sessionKey;
316
+ try {
317
+ const raw = fs.readFileSync('/run/mk0r-session.json', 'utf8');
318
+ sessionKey = (JSON.parse(raw) || {}).sessionKey;
319
+ } catch (err) {
320
+ console.error(`bootstrap-vm: cannot read /run/mk0r-session.json: ${err.message}`);
321
+ process.exit(3);
322
+ }
323
+ if (!sessionKey) {
324
+ console.error('bootstrap-vm: /run/mk0r-session.json has no sessionKey');
325
+ process.exit(3);
326
+ }
327
+ console.log(` sessionKey=${sessionKey}`);
328
+
329
+ // Get the X-Installation header via identity.py (same Python helper http_api.py
330
+ // uses, so auth stays single-sourced).
331
+ const identityPath = path.join(PKG_ROOT, 'scripts', 'identity.py');
332
+ const headerRes = spawnSync('/usr/bin/python3', [identityPath, 'header'], {
333
+ encoding: 'utf8',
334
+ });
335
+ if (headerRes.status !== 0) {
336
+ console.error(`bootstrap-vm: identity.py header failed: ${headerRes.stderr || headerRes.error}`);
337
+ process.exit(4);
338
+ }
339
+ const installHeader = (headerRes.stdout || '').trim();
340
+
341
+ const base = (process.env.AUTOPOSTER_API_BASE || 'https://s4l.ai').replace(/\/+$/, '');
342
+ const url = `${base}/api/v1/twitter/vm-session?session_key=${encodeURIComponent(sessionKey)}`;
343
+ console.log(` GET ${url}`);
344
+
345
+ // Use curl (always present on the appmaker template) so we don't pull in
346
+ // a Node HTTP dep here.
347
+ const curl = spawnSync('curl', [
348
+ '-sS', '--max-time', '15',
349
+ '-H', `X-Installation: ${installHeader}`,
350
+ '-H', 'Content-Type: application/json',
351
+ url,
352
+ ], { encoding: 'utf8' });
353
+ if (curl.status !== 0) {
354
+ console.error(`bootstrap-vm: curl failed: ${curl.stderr || curl.error}`);
355
+ process.exit(5);
356
+ }
357
+ let payload;
358
+ try {
359
+ payload = JSON.parse(curl.stdout || '{}');
360
+ } catch (err) {
361
+ console.error(`bootstrap-vm: bad JSON from API: ${curl.stdout.slice(0, 300)}`);
362
+ process.exit(6);
363
+ }
364
+ if (!payload.ok || !payload.data) {
365
+ console.error(`bootstrap-vm: API error: ${JSON.stringify(payload).slice(0, 300)}`);
366
+ process.exit(7);
367
+ }
368
+ const { handle, cookies, vm_project_id } = payload.data;
369
+ if (!handle) {
370
+ console.error('bootstrap-vm: API returned no handle. social_accounts.vm_session_key may be unset for this VM.');
371
+ process.exit(8);
372
+ }
373
+ console.log(` bound to @${handle} (vm_project_id=${vm_project_id || 'none'}, cookies=${(cookies || []).length})`);
374
+
375
+ // Write env file with DB-sourced handle (durable across `social-autoposter update`).
376
+ writeAppMakerEnvFile(handle);
377
+
378
+ // Existing setup steps. installBrowserHarness already installs uuid-runtime,
379
+ // symlinks the profile, and patches the MCP config — call it directly.
380
+ installBrowserHarness();
381
+
382
+ // Restore the Twitter login if we have stored cookies and the Chrome is
383
+ // up. No-op when Chrome isn't reachable yet (startup ordering); the cycle
384
+ // preflight will run restore_twitter_session.py on its next tick.
385
+ if ((cookies || []).length > 0) {
386
+ const restorePath = path.join(HOME, 'social-autoposter', 'scripts', 'restore_twitter_session.py');
387
+ if (fs.existsSync(restorePath)) {
388
+ console.log(' invoking restore_twitter_session.py to re-inject cookies...');
389
+ // Source the env file so AUTOPOSTER_TWITTER_HANDLE / TWITTER_CDP_URL are set.
390
+ const r = spawnSync('bash', ['-lc',
391
+ `source ${HOME}/.social-autoposter-env 2>/dev/null; /usr/bin/python3 ${restorePath} || true`,
392
+ ], { stdio: 'inherit' });
393
+ void r;
394
+ }
395
+ } else {
396
+ console.log(' no stored cookies; manual login still required this once.');
397
+ }
398
+
399
+ console.log(' bootstrap-vm: done.');
400
+ }
401
+
293
402
  function installBrowserHarness() {
294
403
  const onAppMaker = isAppMakerVm();
295
404
  if (onAppMaker) {
@@ -745,6 +854,8 @@ if (cmd === 'init') {
745
854
  init();
746
855
  } else if (cmd === 'update') {
747
856
  update();
857
+ } else if (cmd === 'bootstrap-vm') {
858
+ bootstrapVm();
748
859
  } else if (cmd === 'export-cookies') {
749
860
  // Forward to cookie-helper with 'export' + remaining args
750
861
  process.argv = [process.argv[0], process.argv[1], 'export', ...process.argv.slice(3)];
@@ -762,6 +873,7 @@ if (cmd === 'init') {
762
873
  console.log(' npx social-autoposter open the dashboard');
763
874
  console.log(' npx social-autoposter init first-time setup');
764
875
  console.log(' npx social-autoposter update update scripts, preserve config');
876
+ console.log(' npx social-autoposter bootstrap-vm AppMaker VM self-bootstrap (DB-driven)');
765
877
  console.log(' npx social-autoposter export-cookies [dir] export browser cookies');
766
878
  console.log(' npx social-autoposter import-cookies [dir] import browser cookies');
767
879
  }
package/bin/server.js CHANGED
@@ -572,6 +572,17 @@ const PLATFORM_LABELS = {
572
572
  hackernews: 'HackerNews', youtube: 'YouTube', instagram: 'Instagram',
573
573
  };
574
574
 
575
+ // twitter-ripen-freshness-abc variant defs (shipped 2026-05-22). Shared between
576
+ // the /api/experiments endpoint (Experiments tab) and the post-comments-twitter
577
+ // row in the job-history pill render so both surfaces describe a variant the
578
+ // same way. When the experiment ends, drop the desc but keep the keys so old
579
+ // history rows still resolve to something readable.
580
+ const TWITTER_VARIANT_DEFS = {
581
+ A: { label: 'Control', desc: 'ripen + 6h freshness (legacy)' },
582
+ B: { label: 'No-ripen', desc: 'skip 20-min wait + 6h freshness' },
583
+ C: { label: 'No-ripen + tight', desc: 'skip 20-min wait + 1h freshness' },
584
+ };
585
+
575
586
  // Standalone jobs with no platform axis. script_name -> display label.
576
587
  const STANDALONE_JOBS = {
577
588
  serp_seo: { job_type: 'seo', job_label: 'SERP SEO' },
@@ -1307,7 +1318,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
1307
1318
  const candidateRows = await pq(
1308
1319
  "SELECT discovered_at, posted_at, t1_checked_at, drafted_at, " +
1309
1320
  " (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id, " +
1310
- " matched_project, tweet_url " +
1321
+ " matched_project, tweet_url, cycle_variant " +
1311
1322
  "FROM twitter_candidates " +
1312
1323
  "WHERE discovered_at >= $1::timestamp OR posted_at >= $1::timestamp OR t1_checked_at >= $1::timestamp OR status='pending'",
1313
1324
  [since]
@@ -1384,9 +1395,34 @@ async function enrichPostCommentsTwitterRuns(runs) {
1384
1395
  batch_id: r.batch_id || '',
1385
1396
  matched_project: r.matched_project || '',
1386
1397
  tweet_url: r.tweet_url || '',
1398
+ cycle_variant: r.cycle_variant || null,
1387
1399
  };
1388
1400
  });
1389
1401
 
1402
+ // Deterministic variant fallback: when a run has an ownBatchId but no
1403
+ // candidate rows yet carry cycle_variant (e.g. Phase 1 aborted before
1404
+ // upserting any row, or rows were rolled back), recompute it the same way
1405
+ // skill/run-twitter-cycle.sh does: sha1(batch_id) % 3 -> 'ABC'. Keeps the
1406
+ // job-history "experiment X" pill populated for scan-only / aborted cycles.
1407
+ // Ship date of twitter-ripen-freshness-abc. Before this, run-twitter-cycle.sh
1408
+ // didn't compute TWITTER_CYCLE_VARIANT and every cycle ran legacy (=variant A
1409
+ // by today's labels). We refuse to back-label those runs from the hash because
1410
+ // it would mislabel them as B/C — they never actually ran those code paths.
1411
+ const EXPERIMENT_SHIP_MS = new Date('2026-05-22T00:00:00').getTime();
1412
+ const variantFromBatchId = (batchId) => {
1413
+ if (!batchId) return null;
1414
+ const batchMs = parseTwitterBatchIdMs(batchId);
1415
+ if (!Number.isFinite(batchMs) || batchMs < EXPERIMENT_SHIP_MS) return null;
1416
+ try {
1417
+ const hex = crypto.createHash('sha1').update(batchId).digest('hex');
1418
+ // Mirror Python's int(hex,16) % 3 exactly. The 160-bit hash doesn't fit
1419
+ // in a JS Number, so use BigInt for the full-width modulus; truncating
1420
+ // hex digits would silently disagree with the shell-side variant.
1421
+ const n = BigInt('0x' + hex);
1422
+ return 'ABC'[Number(n % 3n)];
1423
+ } catch { return null; }
1424
+ };
1425
+
1390
1426
  for (const run of txRuns) {
1391
1427
  const startMs = new Date(run.started_at).getTime();
1392
1428
  const endMs = new Date(run.finished_at).getTime() + 60 * 1000;
@@ -1440,10 +1476,16 @@ async function enrichPostCommentsTwitterRuns(runs) {
1440
1476
  projectsList.push(proj);
1441
1477
  }
1442
1478
  };
1479
+ // Experiment variant (A/B/C) for this run. Primary source: any candidate
1480
+ // row tied to ownBatchId carries cycle_variant (Phase 1 stamps it after
1481
+ // discovery). Fallback: recompute from sha1(ownBatchId) % 3 so scan-only
1482
+ // / aborted cycles still surface the right bucket.
1483
+ let runVariant = null;
1443
1484
  for (const c of candNorm) {
1444
1485
  if (!ownBatchId || c.batch_id !== ownBatchId) continue;
1445
1486
  candidatesPassed++;
1446
1487
  recordProject(c.matched_project);
1488
+ if (!runVariant && c.cycle_variant) runVariant = c.cycle_variant;
1447
1489
  if (c.status === 'posted') {
1448
1490
  posted++;
1449
1491
  // Salvage signature: candidate's discovered_at predates this cycle's
@@ -1622,6 +1664,12 @@ async function enrichPostCommentsTwitterRuns(runs) {
1622
1664
  // pill row so the operator can see at a glance which projects consumed
1623
1665
  // the cycle, even when posted=0. Mirrors enrichPostCommentsRedditRuns.
1624
1666
  projects_worked: projectsList,
1667
+ // A/B/C experiment bucket for this run (twitter-ripen-freshness-abc,
1668
+ // shipped 2026-05-22). Primary source is twitter_candidates.cycle_variant
1669
+ // for any row tied to ownBatchId; falls back to sha1(ownBatchId) % 3 so
1670
+ // scan-only / aborted cycles still surface the variant. Rendered next to
1671
+ // `projects` in the job-history result row.
1672
+ experiment_variant: runVariant || variantFromBatchId(ownBatchId),
1625
1673
  styles_used: stylesUsedTx,
1626
1674
  cost_usd: prior.cost_usd || 0,
1627
1675
  failed: prior.failed || 0,
@@ -5331,6 +5379,109 @@ async function handleApi(req, res) {
5331
5379
  return;
5332
5380
  }
5333
5381
 
5382
+ // GET /api/experiments - currently-running A/B/C tests across the pipeline.
5383
+ // First (and only, for now) experiment is the twitter-cycle variant test
5384
+ // shipped 2026-05-22: variant A = ripen+6h (control), B = no-ripen+6h, C =
5385
+ // no-ripen+1h. Variant assignment lives on twitter_candidates.cycle_variant
5386
+ // (set by skill/run-twitter-cycle.sh from hash(BATCH_ID) % 3). This endpoint
5387
+ // reads that column directly; no separate experiments table yet. When a
5388
+ // second experiment ships we'll either add another block here or promote
5389
+ // to a real table. Admin-only: experimentation state isn't client-facing.
5390
+ if (p === '/api/experiments' && req.method === 'GET') {
5391
+ if (!req.user.admin) return json(res, { error: 'forbidden' }, 403);
5392
+ (async () => {
5393
+ try {
5394
+ // One row per variant, joining candidates -> posts to derive the
5395
+ // post-rate funnel and engagement-on-our-reply. Thread-age-at-discover
5396
+ // p50 uses tweet_posted_at vs discovered_at (we have ~100% coverage on
5397
+ // tweet_posted_at over the experiment window). Engagement is taken from
5398
+ // twitter_candidates.{likes,replies,views} which the stats job refreshes
5399
+ // to T-final values, not the t0/t1 snapshot columns.
5400
+ const rows = await pq(`
5401
+ WITH base AS (
5402
+ SELECT tc.cycle_variant AS variant,
5403
+ tc.id,
5404
+ tc.batch_id,
5405
+ tc.status,
5406
+ tc.discovered_at,
5407
+ tc.tweet_posted_at,
5408
+ tc.posted_at,
5409
+ tc.post_id,
5410
+ EXTRACT(EPOCH FROM (tc.discovered_at - tc.tweet_posted_at)) / 60.0 AS thread_age_min,
5411
+ p.views AS our_views,
5412
+ p.upvotes AS our_likes,
5413
+ p.comments_count AS our_replies
5414
+ FROM twitter_candidates tc
5415
+ LEFT JOIN posts p ON p.id = tc.post_id
5416
+ WHERE tc.cycle_variant IS NOT NULL
5417
+ AND tc.cycle_variant IN ('A','B','C')
5418
+ AND tc.discovered_at > NOW() - INTERVAL '30 days'
5419
+ )
5420
+ SELECT variant,
5421
+ COUNT(*) AS n_candidates,
5422
+ COUNT(DISTINCT batch_id) AS n_batches,
5423
+ COUNT(*) FILTER (WHERE status = 'posted') AS n_posted,
5424
+ COUNT(*) FILTER (WHERE status = 'skipped') AS n_skipped,
5425
+ COUNT(*) FILTER (WHERE status = 'expired') AS n_expired,
5426
+ COUNT(*) FILTER (WHERE status = 'pending') AS n_pending,
5427
+ PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY thread_age_min)
5428
+ FILTER (WHERE thread_age_min IS NOT NULL) AS thread_age_min_p50,
5429
+ AVG(our_views) FILTER (WHERE status = 'posted') AS avg_views,
5430
+ AVG(our_likes) FILTER (WHERE status = 'posted') AS avg_likes,
5431
+ AVG(our_replies) FILTER (WHERE status = 'posted') AS avg_replies,
5432
+ MIN(discovered_at) AS started_at
5433
+ FROM base
5434
+ GROUP BY variant
5435
+ ORDER BY variant
5436
+ `, []);
5437
+ const variantDefs = TWITTER_VARIANT_DEFS;
5438
+ const variants = ['A', 'B', 'C'].map(k => {
5439
+ const row = (rows || []).find(r => r.variant === k) || {};
5440
+ const n = Number(row.n_candidates || 0);
5441
+ const posted = Number(row.n_posted || 0);
5442
+ return {
5443
+ key: k,
5444
+ label: variantDefs[k].label,
5445
+ desc: variantDefs[k].desc,
5446
+ n_candidates: n,
5447
+ n_batches: Number(row.n_batches || 0),
5448
+ n_posted: posted,
5449
+ n_skipped: Number(row.n_skipped || 0),
5450
+ n_expired: Number(row.n_expired || 0),
5451
+ n_pending: Number(row.n_pending || 0),
5452
+ post_rate_pct: n > 0 ? (posted / n) * 100 : null,
5453
+ thread_age_min_p50: row.thread_age_min_p50 != null ? Number(row.thread_age_min_p50) : null,
5454
+ avg_views: row.avg_views != null ? Number(row.avg_views) : null,
5455
+ avg_likes: row.avg_likes != null ? Number(row.avg_likes) : null,
5456
+ avg_replies: row.avg_replies != null ? Number(row.avg_replies) : null,
5457
+ started_at: row.started_at || null,
5458
+ };
5459
+ });
5460
+ const totals = variants.reduce((acc, v) => {
5461
+ acc.n_candidates += v.n_candidates;
5462
+ acc.n_posted += v.n_posted;
5463
+ acc.n_batches += v.n_batches;
5464
+ return acc;
5465
+ }, { n_candidates: 0, n_posted: 0, n_batches: 0 });
5466
+ const startedAt = variants.map(v => v.started_at).filter(Boolean).sort()[0] || null;
5467
+ const experiments = [{
5468
+ id: 'twitter-ripen-freshness-abc',
5469
+ name: 'Twitter ripen + freshness (A/B/C)',
5470
+ status: 'running',
5471
+ started_at: startedAt,
5472
+ hypothesis: 'Skipping the 20-min ripen and tightening discover freshness to 1h cuts thread-age-at-discover (p50 ~181 min on legacy) without hurting post-rate.',
5473
+ primary_metric: 'thread_age_min_p50',
5474
+ totals,
5475
+ variants,
5476
+ }];
5477
+ json(res, { experiments });
5478
+ } catch (e) {
5479
+ json(res, { error: String(e && e.message || e) }, 500);
5480
+ }
5481
+ })();
5482
+ return;
5483
+ }
5484
+
5334
5485
  // GET /api/cost/stats - per-activity-type count + total cost over a trailing
5335
5486
  // window. Types: thread (posts.posted_at), comment (replies.replied),
5336
5487
  // page (seo_keywords + gsc_queries), dm_thread (dms.sent_at). Per-row cost is
@@ -8049,6 +8200,33 @@ const HTML = `<!DOCTYPE html>
8049
8200
  .style-stats-table tfoot td { border-top: 2px solid var(--border-strong, var(--border)); border-bottom: none; background: var(--bg-subtle); font-weight: 600; color: var(--text); }
8050
8201
  .style-stats-table tfoot td:first-child { text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; color: var(--text-secondary); }
8051
8202
  .style-stats-empty { padding: 16px 20px; color: var(--text-muted); font-size: 13px; border-top: 1px solid var(--border); }
8203
+
8204
+ /* Experiments tab: A/B/C cards.
8205
+ Reuses base card chrome from .style-stats-table so it inherits the dashboard theme. */
8206
+ .experiments-header { padding: 20px 20px 8px; }
8207
+ .experiments-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--text); }
8208
+ .experiments-sub { margin-top: 4px; font-size: 12px; color: var(--text-muted); }
8209
+ .exp-kpi-band { display: flex; gap: 24px; padding: 12px 20px 20px; border-bottom: 1px solid var(--border); }
8210
+ .exp-kpi { display: flex; flex-direction: column; gap: 2px; }
8211
+ .exp-kpi-n { font-size: 22px; font-weight: 700; color: var(--text); line-height: 1; }
8212
+ .exp-kpi-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
8213
+ .exp-card { margin: 20px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-subtle); overflow: hidden; }
8214
+ .exp-card-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; padding: 16px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
8215
+ .exp-card-title { font-size: 14px; font-weight: 600; color: var(--text); }
8216
+ .exp-card-hypothesis { margin-top: 4px; font-size: 12px; color: var(--text-secondary); max-width: 720px; line-height: 1.45; }
8217
+ .exp-card-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; font-size: 11px; color: var(--text-muted); }
8218
+ .exp-status { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
8219
+ .exp-status-running { background: var(--accent-panel-bg); color: var(--text); border: 1px solid #3b82f6; }
8220
+ .exp-status-decided { background: #6b21a8; color: #fff; }
8221
+ .exp-status-shipped { background: #166534; color: #fff; }
8222
+ .exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
8223
+ .exp-table th { text-align: left; padding: 10px 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; font-weight: 600; border-bottom: 1px solid var(--border); background: var(--bg); }
8224
+ .exp-table th.num, .exp-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
8225
+ .exp-table td { padding: 12px; border-bottom: 1px solid var(--border); color: var(--text); vertical-align: top; }
8226
+ .exp-table tbody tr:last-child td { border-bottom: none; }
8227
+ .exp-variant-key { display: inline-block; min-width: 22px; text-align: center; padding: 1px 6px; border-radius: 4px; background: var(--bg); border: 1px solid var(--border); font-weight: 700; margin-right: 6px; }
8228
+ .exp-variant-label { font-weight: 600; }
8229
+ .exp-variant-desc { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
8052
8230
  .style-stats-controls { padding: 10px 20px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--text-secondary); }
8053
8231
  .style-stats-pill-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
8054
8232
  .style-stats-pill-row .label { color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; margin-right: 4px; }
@@ -8180,6 +8358,7 @@ const HTML = `<!DOCTYPE html>
8180
8358
  <div class="tab" data-tab="trends">Trends</div>
8181
8359
  <div class="tab" data-tab="activity">Activity</div>
8182
8360
  <div class="tab" data-tab="top">Top</div>
8361
+ <div class="tab sa-admin-only" data-tab="experiments">Experiments</div>
8183
8362
  <div class="tab sa-admin-only" data-tab="logs">Logs</div>
8184
8363
  <div class="tab sa-admin-only" data-tab="settings">Settings</div>
8185
8364
  </div>
@@ -8649,6 +8828,16 @@ const HTML = `<!DOCTYPE html>
8649
8828
  </div>
8650
8829
  </div>
8651
8830
 
8831
+ <div class="content hidden" id="tab-experiments">
8832
+ <div class="experiments-header">
8833
+ <h2 class="experiments-title">Experiments</h2>
8834
+ <div class="experiments-sub">A/B/C tests running across the pipeline. Read-only first cut; one card per active experiment, variants side-by-side.</div>
8835
+ </div>
8836
+ <div id="experiments-list">
8837
+ <div class="style-stats-empty">Loading\u2026</div>
8838
+ </div>
8839
+ </div>
8840
+
8652
8841
  <div class="content hidden" id="tab-logs">
8653
8842
  <div class="log-controls">
8654
8843
  <select id="log-job-filter">
@@ -9165,6 +9354,13 @@ function fmtElapsed(s) {
9165
9354
  return h + 'h ' + (m % 60) + 'm';
9166
9355
  }
9167
9356
 
9357
+ // twitter-ripen-freshness-abc variant defs, baked into the client from the
9358
+ // Node-side TWITTER_VARIANT_DEFS so the Experiments tab and the job-history
9359
+ // pill describe a variant the same way. JSON.stringify keeps it backslash-free
9360
+ // inside this HTML backtick template (any \\n / \\t in here would silently
9361
+ // blank the page per the documented HTML-template gotcha).
9362
+ const TWITTER_VARIANT_DEFS = ${JSON.stringify(TWITTER_VARIANT_DEFS)};
9363
+
9168
9364
  function renderResult(run) {
9169
9365
  const r = run.result || {};
9170
9366
  const pill = (label, n, _color) =>
@@ -9380,6 +9576,23 @@ function renderResult(run) {
9380
9576
  + r.projects_worked.join(', ')
9381
9577
  + '</span></span>'
9382
9578
  : '') +
9579
+ (r.experiment_variant
9580
+ ? (function () {
9581
+ const v = r.experiment_variant;
9582
+ const def = TWITTER_VARIANT_DEFS[v] || null;
9583
+ const desc = def ? def.desc : '';
9584
+ const label = def ? def.label : '';
9585
+ const tooltip = 'twitter-ripen-freshness-abc'
9586
+ + (label ? ' — ' + v + ' ' + label : '')
9587
+ + (desc ? ': ' + desc : '');
9588
+ return '<span title="' + tooltip.replace(/"/g, '&quot;')
9589
+ + '" style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
9590
+ + 'experiment '
9591
+ + '<span style="color:var(--text);font-weight:600;">'
9592
+ + v + (desc ? ': ' + desc : '')
9593
+ + '</span></span>';
9594
+ })()
9595
+ : '') +
9383
9596
  (Array.isArray(r.styles_used) && r.styles_used.length
9384
9597
  ? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
9385
9598
  + 'styles '
@@ -10651,6 +10864,106 @@ function stopLogAutoRefresh() {
10651
10864
  }
10652
10865
 
10653
10866
  // Settings
10867
+ function fmtIntK(n) {
10868
+ if (n == null) return '\u2014';
10869
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
10870
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
10871
+ return String(Math.round(n));
10872
+ }
10873
+
10874
+ function fmtMinHm(min) {
10875
+ if (min == null || !isFinite(min)) return '\u2014';
10876
+ if (min < 60) return Math.round(min) + 'm';
10877
+ const h = Math.floor(min / 60);
10878
+ const m = Math.round(min - h * 60);
10879
+ return h + 'h ' + (m < 10 ? '0' + m : m) + 'm';
10880
+ }
10881
+
10882
+ async function loadExperiments() {
10883
+ const container = document.getElementById('experiments-list');
10884
+ if (!container) return;
10885
+ container.innerHTML = '<div class="style-stats-empty">Loading\u2026</div>';
10886
+ let payload;
10887
+ try {
10888
+ const res = await fetch('/api/experiments');
10889
+ payload = await res.json();
10890
+ if (!res.ok || payload.error) throw new Error(payload.error || ('HTTP ' + res.status));
10891
+ } catch (e) {
10892
+ container.innerHTML = '<div class="style-stats-empty">Failed to load: ' + (e.message || e) + '</div>';
10893
+ return;
10894
+ }
10895
+ const experiments = payload.experiments || [];
10896
+ if (!experiments.length) {
10897
+ container.innerHTML = '<div class="style-stats-empty">No experiments running.</div>';
10898
+ return;
10899
+ }
10900
+ // Top KPI band: count running/decided/shipped across all experiments.
10901
+ const kpiCounts = { running: 0, decided: 0, shipped: 0 };
10902
+ experiments.forEach(e => { if (kpiCounts[e.status] != null) kpiCounts[e.status]++; });
10903
+ const kpiHtml = (
10904
+ '<div class="exp-kpi-band">' +
10905
+ '<div class="exp-kpi"><span class="exp-kpi-n">' + kpiCounts.running + '</span><span class="exp-kpi-label">running</span></div>' +
10906
+ '<div class="exp-kpi"><span class="exp-kpi-n">' + kpiCounts.decided + '</span><span class="exp-kpi-label">decided</span></div>' +
10907
+ '<div class="exp-kpi"><span class="exp-kpi-n">' + kpiCounts.shipped + '</span><span class="exp-kpi-label">shipped</span></div>' +
10908
+ '</div>'
10909
+ );
10910
+ const cards = experiments.map(exp => {
10911
+ const variants = exp.variants || [];
10912
+ const variantRows = variants.map(v => {
10913
+ const postRate = v.post_rate_pct != null ? v.post_rate_pct.toFixed(2) + '%' : '\u2014';
10914
+ const threadAge = fmtMinHm(v.thread_age_min_p50);
10915
+ const views = v.avg_views != null ? fmtIntK(v.avg_views) : '\u2014';
10916
+ const likes = v.avg_likes != null ? fmtIntK(v.avg_likes) : '\u2014';
10917
+ const replies = v.avg_replies != null ? fmtIntK(v.avg_replies) : '\u2014';
10918
+ return (
10919
+ '<tr>' +
10920
+ '<td><span class="exp-variant-key">' + v.key + '</span> <span class="exp-variant-label">' + (v.label || '') + '</span><div class="exp-variant-desc">' + (v.desc || '') + '</div></td>' +
10921
+ '<td class="num">' + fmtIntK(v.n_candidates) + '</td>' +
10922
+ '<td class="num">' + fmtIntK(v.n_batches) + '</td>' +
10923
+ '<td class="num">' + fmtIntK(v.n_posted) + '</td>' +
10924
+ '<td class="num">' + postRate + '</td>' +
10925
+ '<td class="num">' + threadAge + '</td>' +
10926
+ '<td class="num">' + views + '</td>' +
10927
+ '<td class="num">' + likes + '</td>' +
10928
+ '<td class="num">' + replies + '</td>' +
10929
+ '</tr>'
10930
+ );
10931
+ }).join('');
10932
+ const startedTxt = exp.started_at ? new Date(exp.started_at).toLocaleString() : '\u2014';
10933
+ return (
10934
+ '<div class="exp-card">' +
10935
+ '<div class="exp-card-head">' +
10936
+ '<div>' +
10937
+ '<div class="exp-card-title">' + (exp.name || exp.id) + '</div>' +
10938
+ '<div class="exp-card-hypothesis">' + (exp.hypothesis || '') + '</div>' +
10939
+ '</div>' +
10940
+ '<div class="exp-card-meta">' +
10941
+ '<span class="exp-status exp-status-' + exp.status + '">' + exp.status + '</span>' +
10942
+ '<span class="exp-started">started ' + startedTxt + '</span>' +
10943
+ '<span class="exp-totals">' + fmtIntK(exp.totals && exp.totals.n_candidates) + ' candidates / ' + fmtIntK(exp.totals && exp.totals.n_posted) + ' posted</span>' +
10944
+ '<span class="exp-primary">primary: ' + (exp.primary_metric || '\u2014') + '</span>' +
10945
+ '</div>' +
10946
+ '</div>' +
10947
+ '<table class="exp-table">' +
10948
+ '<thead><tr>' +
10949
+ '<th>Variant</th>' +
10950
+ '<th class="num">Cands</th>' +
10951
+ '<th class="num">Batches</th>' +
10952
+ '<th class="num">Posted</th>' +
10953
+ '<th class="num">Post rate</th>' +
10954
+ '<th class="num">Thread age @discover (p50)</th>' +
10955
+ '<th class="num">Views/post</th>' +
10956
+ '<th class="num">Likes/post</th>' +
10957
+ '<th class="num">Replies/post</th>' +
10958
+ '</tr></thead>' +
10959
+ '<tbody>' + variantRows + '</tbody>' +
10960
+ '</table>' +
10961
+ '</div>'
10962
+ );
10963
+ }).join('');
10964
+ container.innerHTML = kpiHtml + cards;
10965
+ }
10966
+
10654
10967
  async function loadSettings() {
10655
10968
  try {
10656
10969
  const [configRes, envRes] = await Promise.all([fetch('/api/config'), fetch('/api/env')]);
@@ -17427,6 +17740,12 @@ function activateTab(name) {
17427
17740
  loadSettings();
17428
17741
  _tabLoaded.settings = true;
17429
17742
  }
17743
+ if (name === 'experiments') {
17744
+ // Always refetch: cycles fire every 15 min, so stale data is more painful
17745
+ // than the cheap pq() roundtrip. Single endpoint, single SQL pass.
17746
+ loadExperiments();
17747
+ _tabLoaded.experiments = true;
17748
+ }
17430
17749
  return true;
17431
17750
  }
17432
17751
  document.querySelectorAll('.tab').forEach(tab => {
@@ -17447,7 +17766,7 @@ _saInstallDeleteListener();
17447
17766
  // visible to the user (some tabs are admin-only).
17448
17767
  (function restoreActiveTab() {
17449
17768
  const saved = saLoad('sa.activeTab.v1', null);
17450
- const valid = ['status', 'stats', 'trends', 'activity', 'top', 'logs', 'settings'];
17769
+ const valid = ['status', 'stats', 'trends', 'activity', 'top', 'experiments', 'logs', 'settings'];
17451
17770
  if (!saved || !valid.includes(saved) || saved === 'stats') return;
17452
17771
  // Defer a tick so admin-only visibility (driven by auth init) settles
17453
17772
  // before we try to switch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.13",
3
+ "version": "1.6.14",
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"
@@ -107,6 +107,57 @@ CREATE INDEX IF NOT EXISTS idx_thread_top_replies_refresh
107
107
  ON thread_top_replies(platform, status, engagement_updated_at)
108
108
  WHERE status = 'active';
109
109
 
110
+ -- 2026-05-22: snowflake-derived posted_at on both rails (see
111
+ -- migrations/2026-05-22-snowflake-derived-posted-at.sql for the full
112
+ -- rationale). Twitter snowflake IDs encode their creation timestamp:
113
+ -- ts_ms = (id >> 22) + 1288834974657. We already store reply_tweet_id
114
+ -- on thread_top_replies and the thread tweet ID lives in
115
+ -- posts.thread_url, so both columns are derivable arithmetically. Using
116
+ -- GENERATED STORED so the routes never need to know.
117
+ ALTER TABLE thread_top_replies
118
+ ADD COLUMN IF NOT EXISTS reply_posted_at TIMESTAMPTZ
119
+ GENERATED ALWAYS AS (
120
+ CASE
121
+ WHEN reply_tweet_id ~ '^\d+$'
122
+ THEN to_timestamp(
123
+ ((reply_tweet_id::bigint) >> 22) / 1000.0 + 1288834974.657
124
+ )
125
+ ELSE NULL
126
+ END
127
+ ) STORED;
128
+
129
+ ALTER TABLE posts
130
+ ADD COLUMN IF NOT EXISTS thread_posted_at TIMESTAMPTZ
131
+ GENERATED ALWAYS AS (
132
+ CASE
133
+ WHEN thread_url ~ '/status/\d+'
134
+ THEN to_timestamp(
135
+ ((substring(thread_url FROM '/status/(\d+)')::bigint) >> 22) / 1000.0 + 1288834974.657
136
+ )
137
+ ELSE NULL
138
+ END
139
+ ) STORED;
140
+
141
+ CREATE INDEX IF NOT EXISTS idx_posts_platform_thread_posted_at
142
+ ON posts (platform, thread_posted_at)
143
+ WHERE thread_posted_at IS NOT NULL;
144
+
145
+ -- 2026-05-22: link metadata on thread_top_replies (see
146
+ -- migrations/2026-05-22-thread-top-replies-link-metadata.sql). The
147
+ -- snapshot now captures min 1, max 2 replies per thread: rank=1 is
148
+ -- top by likes regardless of link; rank=2 is the top link-bearing
149
+ -- reply (if one exists and differs by URL). has_link is a derived
150
+ -- convenience flag for analytics + partial indexes.
151
+ ALTER TABLE thread_top_replies
152
+ ADD COLUMN IF NOT EXISTS reply_link_url TEXT,
153
+ ADD COLUMN IF NOT EXISTS reply_link_display TEXT;
154
+ ALTER TABLE thread_top_replies
155
+ ADD COLUMN IF NOT EXISTS has_link BOOLEAN
156
+ GENERATED ALWAYS AS (reply_link_url IS NOT NULL) STORED;
157
+ CREATE INDEX IF NOT EXISTS idx_thread_top_replies_has_link
158
+ ON thread_top_replies(post_id, has_link)
159
+ WHERE has_link = TRUE;
160
+
110
161
  CREATE TABLE IF NOT EXISTS threads (
111
162
  id SERIAL PRIMARY KEY,
112
163
  platform TEXT NOT NULL,