social-autoposter 1.3.4 → 1.3.5

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/server.js CHANGED
@@ -5438,20 +5438,44 @@ async function handleApi(req, res) {
5438
5438
  };
5439
5439
  }).sort((a, b) => b.weight - a.weight || a.name.localeCompare(b.name));
5440
5440
  // Surface any posts that didn't match a weighted project, so the matrix adds up.
5441
+ // configured = the project name is in config.json (just at weight 0), so the
5442
+ // weight editor stays available. unconfigured rows come from stale project
5443
+ // names found in post rows only and aren't editable here.
5441
5444
  const knownNames = new Set(weighted.map(p => p.name));
5445
+ const configuredNames = new Set(configuredProjects.map(p => p.name));
5446
+ const configuredByName = Object.fromEntries(configuredProjects.map(p => [p.name, p]));
5442
5447
  const unassigned = Object.entries(byProject)
5443
5448
  .filter(([name]) => !knownNames.has(name))
5444
5449
  .map(([name, stats]) => ({
5445
5450
  name,
5446
- weight: 0,
5451
+ weight: Number((configuredByName[name] || {}).weight) || 0,
5447
5452
  target_share: 0,
5448
5453
  total: stats.total,
5449
5454
  actual_share: grandTotal > 0 ? stats.total / grandTotal : 0,
5450
5455
  deficit: -(grandTotal > 0 ? stats.total / grandTotal : 0),
5451
5456
  by_platform: Object.fromEntries(platforms.map(pl => [pl, stats.by_platform[pl] || 0])),
5452
- website: null,
5457
+ website: (configuredByName[name] && configuredByName[name].website) || null,
5453
5458
  unassigned: true,
5459
+ configured: configuredNames.has(name),
5454
5460
  }));
5461
+ // Also include configured projects with weight=0 and zero posts in the
5462
+ // window, so an operator can lift them back up from the table.
5463
+ configuredProjects.forEach(cp => {
5464
+ if (knownNames.has(cp.name)) return;
5465
+ if (byProject[cp.name]) return;
5466
+ unassigned.push({
5467
+ name: cp.name,
5468
+ weight: Number(cp.weight) || 0,
5469
+ target_share: 0,
5470
+ total: 0,
5471
+ actual_share: 0,
5472
+ deficit: 0,
5473
+ by_platform: Object.fromEntries(platforms.map(pl => [pl, 0])),
5474
+ website: cp.website || null,
5475
+ unassigned: true,
5476
+ configured: true,
5477
+ });
5478
+ });
5455
5479
  // Per-project Claude cost in the same window. Mirrors /api/cost/stats
5456
5480
  // attribution: per_row_cost = COALESCE(orchestrator_cost_usd,
5457
5481
  // total_cost_usd) / rows_in_session, summed across the activity rows
@@ -5572,11 +5596,44 @@ async function handleApi(req, res) {
5572
5596
  grand_cost_usd_orchestrator: grandCostOrch,
5573
5597
  grand_cost_usd_estimated: grandCostEst,
5574
5598
  cost_available: !!(req.user && req.user.admin),
5599
+ can_edit_weight: !auth.CLIENT_MODE && !!(req.user && req.user.admin),
5575
5600
  projects,
5576
5601
  unassigned,
5577
5602
  });
5578
5603
  }
5579
5604
 
5605
+ // POST /api/project/weight - update one project's weight in config.json.
5606
+ // Admin only and disabled in CLIENT_MODE (no config.json on Cloud Run).
5607
+ // Body: { name: string, weight: number }. Weight must be a non-negative
5608
+ // finite number. The picker reads config.json directly on each pick, so no
5609
+ // restart or cache bust is needed.
5610
+ if (p === '/api/project/weight' && req.method === 'POST') {
5611
+ if (auth.CLIENT_MODE) return json(res, { error: 'config_readonly_in_client_mode' }, 405);
5612
+ if (!req.user || !req.user.admin) return json(res, { error: 'forbidden' }, 403);
5613
+ return readBody(req).then(body => {
5614
+ let payload;
5615
+ try { payload = JSON.parse(body); }
5616
+ catch { return json(res, { error: 'invalid_json' }, 400); }
5617
+ const name = typeof payload.name === 'string' ? payload.name.trim() : '';
5618
+ const weight = Number(payload.weight);
5619
+ if (!name) return json(res, { error: 'name_required' }, 400);
5620
+ if (!Number.isFinite(weight) || weight < 0 || weight > 1e6) {
5621
+ return json(res, { error: 'invalid_weight' }, 400);
5622
+ }
5623
+ let config;
5624
+ try { config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
5625
+ catch (e) { return json(res, { error: 'config_read_failed', detail: e.message }, 500); }
5626
+ const projects = Array.isArray(config.projects) ? config.projects : [];
5627
+ const target = projects.find(pr => pr && pr.name === name);
5628
+ if (!target) return json(res, { error: 'project_not_found', name }, 404);
5629
+ const previous = Number(target.weight) || 0;
5630
+ target.weight = weight;
5631
+ try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n'); }
5632
+ catch (e) { return json(res, { error: 'config_write_failed', detail: e.message }, 500); }
5633
+ return json(res, { saved: true, name, weight, previous });
5634
+ }).catch(e => json(res, { error: e.message }, 400));
5635
+ }
5636
+
5580
5637
  // GET /api/deploy/status - latest Vercel production deploy per project.
5581
5638
  // Written every ~5 min to skill/cache/deploy_status.json by launchd
5582
5639
  // com.m13v.social-deploy-status (scripts/project_deploy_status.py). If the
@@ -13529,6 +13586,7 @@ function renderProjectStatus(data) {
13529
13586
  const grandTotal = Number(data && data.grand_total) || 0;
13530
13587
  const totals = (data && data.platform_totals) || {};
13531
13588
  const costAvailable = !!(data && data.cost_available);
13589
+ const canEditWeight = !!(data && data.can_edit_weight);
13532
13590
  const grandCost = Number(data && data.grand_cost_usd) || 0;
13533
13591
  const grandCostOrch = Number(data && data.grand_cost_usd_orchestrator) || 0;
13534
13592
  const grandCostEst = Number(data && data.grand_cost_usd_estimated) || 0;
@@ -13635,9 +13693,21 @@ function renderProjectStatus(data) {
13635
13693
  const costCellHtml = costAvailable
13636
13694
  ? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, { extra: 'color:var(--text-secondary);' })
13637
13695
  : '';
13696
+ const weightVal = Number(r.weight) || 0;
13697
+ const editable = canEditWeight && (!r.unassigned || r.configured);
13698
+ const weightCellHtml = editable
13699
+ ? '<td style="text-align:right;font-variant-numeric:tabular-nums;">' +
13700
+ '<input type="number" min="0" step="1" value="' + weightVal + '" ' +
13701
+ 'data-project-weight-input="' + escapeHtml(r.name) + '" ' +
13702
+ 'data-original-weight="' + weightVal + '" ' +
13703
+ 'class="project-weight-input" ' +
13704
+ 'style="width:60px;text-align:right;font-variant-numeric:tabular-nums;padding:2px 6px;border:1px solid var(--border);background:var(--bg-subtle,transparent);color:var(--text);border-radius:4px;font-size:13px;font-weight:600;" ' +
13705
+ 'title="Edit and press Enter or blur to save" />' +
13706
+ '</td>'
13707
+ : '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + weightVal + '</td>';
13638
13708
  return '<tr>' +
13639
13709
  '<td style="text-align:left;font-weight:600;">' + nameLabel + '</td>' +
13640
- '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (r.weight || 0) + '</td>' +
13710
+ weightCellHtml +
13641
13711
  '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + (r.unassigned ? '&mdash;' : formatPct(r.target_share)) + '</td>' +
13642
13712
  platformCells +
13643
13713
  totalCell +
@@ -13668,6 +13738,61 @@ function renderProjectStatus(data) {
13668
13738
  '<tbody>' + bodyRows + footerHtml + '</tbody>' +
13669
13739
  '</table>' +
13670
13740
  '</div>' + legend;
13741
+ if (canEditWeight) {
13742
+ body.querySelectorAll('input.project-weight-input').forEach(inp => {
13743
+ inp.addEventListener('keydown', e => {
13744
+ if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
13745
+ else if (e.key === 'Escape') {
13746
+ inp.value = inp.dataset.originalWeight || '0';
13747
+ inp.blur();
13748
+ }
13749
+ });
13750
+ inp.addEventListener('blur', () => saveProjectWeight(inp));
13751
+ });
13752
+ }
13753
+ }
13754
+ async function saveProjectWeight(inp) {
13755
+ const name = inp.dataset.projectWeightInput;
13756
+ const original = Number(inp.dataset.originalWeight) || 0;
13757
+ const raw = inp.value.trim();
13758
+ const next = Number(raw);
13759
+ if (!name) return;
13760
+ if (raw === '' || !Number.isFinite(next) || next < 0) {
13761
+ inp.value = String(original);
13762
+ return;
13763
+ }
13764
+ if (Math.trunc(next) === Math.trunc(original) && next === original) return;
13765
+ inp.disabled = true;
13766
+ const prevBorder = inp.style.borderColor;
13767
+ inp.style.borderColor = 'var(--text-muted)';
13768
+ try {
13769
+ const res = await fetch('/api/project/weight', {
13770
+ method: 'POST',
13771
+ headers: { 'Content-Type': 'application/json' },
13772
+ body: JSON.stringify({ name, weight: next }),
13773
+ });
13774
+ const data = await res.json().catch(() => ({}));
13775
+ if (!res.ok || data.error) {
13776
+ inp.value = String(original);
13777
+ inp.style.borderColor = '#b91c1c';
13778
+ setTimeout(() => { inp.style.borderColor = prevBorder; }, 1500);
13779
+ console.error('[project-weight] save failed', data);
13780
+ return;
13781
+ }
13782
+ inp.dataset.originalWeight = String(next);
13783
+ inp.style.borderColor = '#15803d';
13784
+ setTimeout(() => { inp.style.borderColor = prevBorder; }, 800);
13785
+ try { window.posthog && window.posthog.capture('project_weight_edit', { project: name, weight: next, previous: original }); } catch (er) {}
13786
+ _projectStatusLoading = false;
13787
+ loadProjectStatus(true);
13788
+ } catch (e) {
13789
+ inp.value = String(original);
13790
+ inp.style.borderColor = '#b91c1c';
13791
+ setTimeout(() => { inp.style.borderColor = prevBorder; }, 1500);
13792
+ console.error('[project-weight] save error', e);
13793
+ } finally {
13794
+ inp.disabled = false;
13795
+ }
13671
13796
  }
13672
13797
  async function refreshAllData() {
13673
13798
  const icon = document.getElementById('global-refresh-icon');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
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"
@@ -20,10 +20,39 @@ import os
20
20
  import sys
21
21
 
22
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
- import db
24
23
 
25
24
 
26
- def get_active_campaigns(platform):
25
+ def _get_active_via_api(platform):
26
+ from http_api import api_get
27
+ resp = api_get(
28
+ "/api/v1/campaigns",
29
+ query={
30
+ "status": "active",
31
+ "platform": platform,
32
+ "with_budget_remaining": "true",
33
+ "limit": 500,
34
+ },
35
+ )
36
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
37
+ out = []
38
+ for r in rows:
39
+ max_total = r.get("max_posts_total")
40
+ posts_made = r.get("posts_made") or 0
41
+ if max_total is None or posts_made >= max_total:
42
+ continue
43
+ out.append({
44
+ "id": int(r["id"]),
45
+ "name": r.get("name"),
46
+ "prompt": r.get("prompt"),
47
+ "max_posts_total": int(max_total),
48
+ "posts_made": int(posts_made),
49
+ "remaining": int(max_total) - int(posts_made),
50
+ })
51
+ return out
52
+
53
+
54
+ def _get_active_via_neon(platform):
55
+ import db
27
56
  conn = db.get_conn()
28
57
  try:
29
58
  cur = conn.execute(
@@ -41,7 +70,6 @@ def get_active_campaigns(platform):
41
70
  rows = cur.fetchall()
42
71
  finally:
43
72
  conn.close()
44
-
45
73
  return [
46
74
  {
47
75
  "id": r[0],
@@ -55,6 +83,18 @@ def get_active_campaigns(platform):
55
83
  ]
56
84
 
57
85
 
86
+ def get_active_campaigns(platform):
87
+ """Active campaigns for `platform` with budget remaining.
88
+
89
+ Routes through /api/v1/campaigns by default so VMs without
90
+ DATABASE_URL still get the active list. Set
91
+ SOCIAL_AUTOPOSTER_LEGACY_NEON=1 for the direct-Neon path.
92
+ """
93
+ if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
94
+ return _get_active_via_neon(platform)
95
+ return _get_active_via_api(platform)
96
+
97
+
58
98
  def format_prompt_block(campaigns, repo_dir):
59
99
  if not campaigns:
60
100
  return ""
@@ -9,6 +9,9 @@ Usage:
9
9
  The named row's campaign_id column is set to the given campaign, and the
10
10
  campaign's posts_made counter advances by one. Idempotent: if the row already
11
11
  references this campaign, no counter bump happens.
12
+
13
+ Routes through /api/v1/campaigns/bump by default. Set
14
+ SOCIAL_AUTOPOSTER_LEGACY_NEON=1 to use direct Neon.
12
15
  """
13
16
 
14
17
  import argparse
@@ -16,37 +19,57 @@ import os
16
19
  import sys
17
20
 
18
21
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
- import db
20
22
 
21
23
  ALLOWED_TABLES = {"posts", "replies", "dm_messages"}
22
24
 
23
25
 
24
- def main():
25
- ap = argparse.ArgumentParser()
26
- ap.add_argument("--table", required=True, choices=sorted(ALLOWED_TABLES))
27
- ap.add_argument("--id", type=int, required=True)
28
- ap.add_argument("--campaign-id", type=int, required=True)
29
- args = ap.parse_args()
26
+ def _bump_via_api(table, row_id, campaign_id):
27
+ from http_api import api_post
28
+ resp = api_post(
29
+ "/api/v1/campaigns/bump",
30
+ {"table": table, "id": int(row_id), "campaign_id": int(campaign_id)},
31
+ )
32
+ data = (resp or {}).get("data") or {}
33
+ bumped = bool(data.get("bumped"))
34
+ return bumped
30
35
 
36
+
37
+ def _bump_via_neon(table, row_id, campaign_id):
38
+ import db
31
39
  conn = db.get_conn()
32
40
  try:
33
41
  cur = conn.execute(
34
- f"UPDATE {args.table} SET campaign_id = %s "
42
+ f"UPDATE {table} SET campaign_id = %s "
35
43
  f"WHERE id = %s AND (campaign_id IS NULL OR campaign_id <> %s) "
36
44
  f"RETURNING id",
37
- [args.campaign_id, args.id, args.campaign_id],
45
+ [campaign_id, row_id, campaign_id],
38
46
  )
39
47
  bumped = cur.fetchone() is not None
40
48
  if bumped:
41
49
  conn.execute(
42
50
  "UPDATE campaigns SET posts_made = posts_made + 1, updated_at = NOW() "
43
51
  "WHERE id = %s",
44
- [args.campaign_id],
52
+ [campaign_id],
45
53
  )
46
54
  conn.commit()
47
- print(f"table={args.table} id={args.id} campaign={args.campaign_id} bumped={bumped}")
55
+ return bumped
48
56
  finally:
49
57
  conn.close()
58
+
59
+
60
+ def main():
61
+ ap = argparse.ArgumentParser()
62
+ ap.add_argument("--table", required=True, choices=sorted(ALLOWED_TABLES))
63
+ ap.add_argument("--id", type=int, required=True)
64
+ ap.add_argument("--campaign-id", type=int, required=True)
65
+ args = ap.parse_args()
66
+
67
+ if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
68
+ bumped = _bump_via_neon(args.table, args.id, args.campaign_id)
69
+ else:
70
+ bumped = _bump_via_api(args.table, args.id, args.campaign_id)
71
+
72
+ print(f"table={args.table} id={args.id} campaign={args.campaign_id} bumped={bumped}")
50
73
  return 0
51
74
 
52
75
 
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """Email-alert when any external_short_links pool runs low.
3
+
4
+ For every project with `external_short_links: true` in config.json, checks the
5
+ (project, platform) pool depth against two thresholds:
6
+
7
+ WARN -- available / total <= 0.20 (i.e., 80% of the pool has been claimed)
8
+ CRITICAL -- available == 0 (pool exhausted, next post returns
9
+ {ok: false, error: 'pool_exhausted'})
10
+
11
+ Emails go to i@m13v.com via the Gmail DWD lane. State lives in the
12
+ `external_pool_alerts` table so we don't spam: same (project, platform,
13
+ severity) is suppressed for 24h after a send.
14
+
15
+ Designed to run on launchd every 30 min. The 20% threshold gives 7-30 days of
16
+ runway warning before a CRITICAL fires (at typical 5-15 posts/day burn). The
17
+ CRITICAL alert is the "on error" case the user asked for; it fires at most
18
+ once per 24h per (project, platform).
19
+
20
+ Usage:
21
+ python3 scripts/check_external_pool_depth.py # check + alert
22
+ python3 scripts/check_external_pool_depth.py --dry-run # report only, no email/state writes
23
+ python3 scripts/check_external_pool_depth.py --force # ignore 24h cooldown
24
+ """
25
+ from __future__ import annotations
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ from datetime import datetime, timezone
31
+ from email.message import EmailMessage
32
+ import base64
33
+
34
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35
+ sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
36
+ sys.path.insert(0, os.path.expanduser('~/gmail-api'))
37
+
38
+ import db as dbmod # noqa: E402
39
+
40
+ WARN_REMAINING_RATIO = 0.20
41
+ COOLDOWN_HOURS = 24
42
+ NOTIFICATION_EMAIL = os.environ.get('NOTIFICATION_EMAIL', 'i@m13v.com')
43
+ PLATFORMS = ['reddit', 'twitter', 'linkedin', 'github_issues', 'moltbook']
44
+ CONFIG_PATH = os.path.join(REPO_DIR, 'config.json')
45
+
46
+
47
+ def _scrub_dashes(s: str) -> str:
48
+ return s.replace('—', ',').replace('–', ',') if s else s
49
+
50
+
51
+ def _load_external_projects() -> list[dict]:
52
+ with open(CONFIG_PATH) as f:
53
+ cfg = json.load(f)
54
+ return [p for p in cfg.get('projects', []) if p.get('external_short_links')]
55
+
56
+
57
+ def _pool_depth(conn, project: str, platform: str) -> tuple[int, int]:
58
+ cur = conn.execute(
59
+ "SELECT "
60
+ " COUNT(*) FILTER (WHERE post_id IS NULL AND reply_id IS NULL) AS available, "
61
+ " COUNT(*) AS total "
62
+ "FROM post_links "
63
+ "WHERE minted_session LIKE %s AND project_name = %s AND platform = %s",
64
+ ('pool:%', project, platform),
65
+ )
66
+ r = dict(cur.fetchone())
67
+ return int(r['available'] or 0), int(r['total'] or 0)
68
+
69
+
70
+ def _recent_alert_exists(conn, project: str, platform: str, severity: str) -> bool:
71
+ cur = conn.execute(
72
+ "SELECT 1 FROM external_pool_alerts "
73
+ "WHERE project_name=%s AND platform=%s AND severity=%s "
74
+ " AND sent_at > NOW() - INTERVAL %s "
75
+ "LIMIT 1",
76
+ (project, platform, severity, f"{COOLDOWN_HOURS} hours"),
77
+ )
78
+ return cur.fetchone() is not None
79
+
80
+
81
+ def _record_alert(conn, project: str, platform: str, severity: str,
82
+ available: int, total: int, ratio: float) -> None:
83
+ conn.execute(
84
+ "INSERT INTO external_pool_alerts "
85
+ " (project_name, platform, severity, available, total, ratio) "
86
+ "VALUES (%s, %s, %s, %s, %s, %s)",
87
+ (project, platform, severity, available, total, ratio),
88
+ )
89
+ conn.commit()
90
+
91
+
92
+ def _gmail_send(subject: str, body: str) -> None:
93
+ from gmail_dwd_client import gmail_for
94
+ msg = EmailMessage()
95
+ msg['Subject'] = _scrub_dashes(subject)
96
+ msg['From'] = 'social-autoposter <i@m13v.com>'
97
+ msg['To'] = NOTIFICATION_EMAIL
98
+ msg.set_content(_scrub_dashes(body))
99
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode('ascii')
100
+ client = gmail_for('i@m13v.com')
101
+ client.service.users().messages().send(userId='me', body={'raw': raw}).execute()
102
+
103
+
104
+ def _format_subject(project: str, platform: str, severity: str,
105
+ available: int, total: int) -> str:
106
+ pct = f"{(available / total * 100):.0f}%" if total else "0%"
107
+ return f"[POOL {severity}] {project}/{platform}: {available}/{total} left ({pct})"
108
+
109
+
110
+ def _format_body(project: str, platform: str, severity: str,
111
+ available: int, total: int, ratio: float,
112
+ destinations: list[dict]) -> str:
113
+ lines = [
114
+ f"Severity: {severity}",
115
+ f"Project: {project}",
116
+ f"Platform: {platform}",
117
+ f"Available: {available}",
118
+ f"Total minted: {total}",
119
+ f"Remaining: {ratio*100:.1f}%",
120
+ "",
121
+ ]
122
+ if severity == 'CRITICAL':
123
+ lines += [
124
+ "The pool is exhausted. The next post for this (project, platform) will",
125
+ "return {ok: false, error: 'pool_exhausted'} and skip. Refill IMMEDIATELY:",
126
+ "",
127
+ ]
128
+ else:
129
+ lines += [
130
+ f"Pool has dropped under {int(WARN_REMAINING_RATIO*100)}% remaining. Schedule a refill",
131
+ "in the next few days to avoid hitting pool_exhausted on the next cycle.",
132
+ "",
133
+ ]
134
+ lines += [
135
+ "Refill commands (in ~/social-autoposter):",
136
+ " python3 scripts/mint_kent_pool.py # Kent clients (Runner/Agora/Podlog)",
137
+ " # for other external clients: extend mint_kent_pool.py SITE_CONFIG first",
138
+ "",
139
+ "Pool status snapshot:",
140
+ " python3 scripts/mint_kent_pool.py --status",
141
+ "",
142
+ ]
143
+ if destinations:
144
+ lines += ["Per-destination breakdown for this slice:"]
145
+ for d in destinations[:20]:
146
+ lines.append(
147
+ f" {d['minted_session'][-65:]:<65} "
148
+ f"avail={d['available']:>5} claimed={d['claimed']:>5}"
149
+ )
150
+ if len(destinations) > 20:
151
+ lines.append(f" ... and {len(destinations) - 20} more destinations")
152
+ lines.append("")
153
+ lines += [
154
+ f"Cooldown: {COOLDOWN_HOURS}h per (project, platform, severity)",
155
+ f"Re-fire: python3 scripts/check_external_pool_depth.py --force",
156
+ ]
157
+ return "\n".join(lines)
158
+
159
+
160
+ def _destinations_for_slice(conn, project: str, platform: str) -> list[dict]:
161
+ cur = conn.execute(
162
+ "SELECT minted_session, "
163
+ " COUNT(*) FILTER (WHERE post_id IS NULL AND reply_id IS NULL) AS available, "
164
+ " COUNT(*) FILTER (WHERE post_id IS NOT NULL OR reply_id IS NOT NULL) AS claimed "
165
+ "FROM post_links "
166
+ "WHERE minted_session LIKE %s AND project_name = %s AND platform = %s "
167
+ "GROUP BY minted_session ORDER BY available ASC",
168
+ ('pool:%', project, platform),
169
+ )
170
+ return [dict(r) for r in cur.fetchall()]
171
+
172
+
173
+ def check(dry_run: bool = False, force: bool = False,
174
+ warn_ratio: float = WARN_REMAINING_RATIO,
175
+ limit: int | None = None) -> dict:
176
+ projects = _load_external_projects()
177
+ conn = dbmod.get_conn()
178
+ fired: list[dict] = []
179
+ skipped_cooldown: list[dict] = []
180
+ healthy: list[dict] = []
181
+ try:
182
+ for p in projects:
183
+ project_name = p['name']
184
+ for platform in PLATFORMS:
185
+ available, total = _pool_depth(conn, project_name, platform)
186
+ if total == 0:
187
+ continue
188
+ ratio = available / total if total > 0 else 0.0
189
+ if available == 0:
190
+ severity = 'CRITICAL'
191
+ elif ratio <= warn_ratio:
192
+ severity = 'WARN'
193
+ else:
194
+ healthy.append({
195
+ 'project': project_name, 'platform': platform,
196
+ 'available': available, 'total': total, 'ratio': ratio,
197
+ })
198
+ continue
199
+ key = (project_name, platform, severity)
200
+ if not force and _recent_alert_exists(conn, *key):
201
+ skipped_cooldown.append({
202
+ 'project': project_name, 'platform': platform,
203
+ 'severity': severity, 'available': available, 'total': total,
204
+ })
205
+ continue
206
+ fired_row = {
207
+ 'project': project_name, 'platform': platform,
208
+ 'severity': severity, 'available': available, 'total': total,
209
+ 'ratio': ratio,
210
+ }
211
+ fired.append(fired_row)
212
+ if dry_run:
213
+ continue
214
+ if limit is not None and len(fired) > limit:
215
+ continue
216
+ destinations = _destinations_for_slice(conn, project_name, platform)
217
+ subject = _format_subject(project_name, platform, severity, available, total)
218
+ body = _format_body(project_name, platform, severity, available, total,
219
+ ratio, destinations)
220
+ try:
221
+ _gmail_send(subject, body)
222
+ _record_alert(conn, project_name, platform, severity,
223
+ available, total, ratio)
224
+ except Exception as e:
225
+ fired_row['send_error'] = str(e)
226
+ print(f"[pool-check] email send failed for {project_name}/{platform}: {e}",
227
+ file=sys.stderr)
228
+ return {
229
+ 'checked_at': datetime.now(timezone.utc).isoformat(),
230
+ 'fired': fired,
231
+ 'skipped_cooldown': skipped_cooldown,
232
+ 'healthy_count': len(healthy),
233
+ 'dry_run': dry_run,
234
+ }
235
+ finally:
236
+ conn.close()
237
+
238
+
239
+ def main():
240
+ ap = argparse.ArgumentParser(description=__doc__)
241
+ ap.add_argument('--dry-run', action='store_true',
242
+ help='compute and report, do not email or write state')
243
+ ap.add_argument('--force', action='store_true',
244
+ help='ignore 24h cooldown, re-fire matching alerts')
245
+ ap.add_argument('--warn-ratio', type=float, default=WARN_REMAINING_RATIO,
246
+ help=f'WARN threshold for available/total (default {WARN_REMAINING_RATIO})')
247
+ ap.add_argument('--limit', type=int, default=None,
248
+ help='cap the number of alert emails per run (smoke testing)')
249
+ args = ap.parse_args()
250
+ result = check(dry_run=args.dry_run, force=args.force,
251
+ warn_ratio=args.warn_ratio, limit=args.limit)
252
+ print(json.dumps(result, indent=2, default=str))
253
+
254
+
255
+ if __name__ == '__main__':
256
+ main()
@@ -70,37 +70,15 @@ def main():
70
70
  return
71
71
 
72
72
  try:
73
- import psycopg2 # noqa: F401 (psycopg2 only needed via dbmod transitively)
74
73
  sys.path.insert(0, os.path.join(ROOT_DIR, 'scripts'))
75
- import db as dbmod
76
- conn = dbmod.get_conn()
77
- if cycle_id:
78
- cur = conn.execute(
79
- """SELECT COALESCE(SUM(total_cost_usd), 0),
80
- COALESCE(SUM(subagent_cost_usd), 0),
81
- COALESCE(SUM(task_call_count), 0),
82
- COALESCE(SUM(subagent_count), 0)
83
- FROM claude_sessions
84
- WHERE cycle_id = %s""",
85
- [cycle_id],
74
+ if os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') == '1':
75
+ parent_cost, subagent_cost, task_count, subagent_count = _fetch_via_neon(
76
+ cycle_id=cycle_id, since=args.since, scripts=args.scripts,
86
77
  )
87
78
  else:
88
- since_ts = datetime.fromtimestamp(args.since, tz=timezone.utc).isoformat()
89
- placeholders = ','.join(['%s'] * len(args.scripts))
90
- cur = conn.execute(
91
- f"""SELECT COALESCE(SUM(total_cost_usd), 0),
92
- COALESCE(SUM(subagent_cost_usd), 0),
93
- COALESCE(SUM(task_call_count), 0),
94
- COALESCE(SUM(subagent_count), 0)
95
- FROM claude_sessions
96
- WHERE script IN ({placeholders}) AND started_at >= %s""",
97
- args.scripts + [since_ts],
79
+ parent_cost, subagent_cost, task_count, subagent_count = _fetch_via_api(
80
+ cycle_id=cycle_id, since=args.since, scripts=args.scripts,
98
81
  )
99
- row = cur.fetchone()
100
- parent_cost = float(row[0] or 0)
101
- subagent_cost = float(row[1] or 0)
102
- task_count = int(row[2] or 0)
103
- subagent_count = int(row[3] or 0)
104
82
  if args.breakdown:
105
83
  print(f"{parent_cost:.4f} {subagent_cost:.4f} {task_count} {subagent_count}")
106
84
  else:
@@ -109,5 +87,56 @@ def main():
109
87
  print("0.0000")
110
88
 
111
89
 
90
+ def _fetch_via_api(*, cycle_id, since, scripts):
91
+ from http_api import api_get
92
+ if cycle_id:
93
+ query = {"cycle_id": cycle_id}
94
+ else:
95
+ query = {"since_ts": str(int(since)), "scripts": ",".join(scripts)}
96
+ resp = api_get("/api/v1/claude-sessions/cost", query=query)
97
+ data = (resp or {}).get("data") or {}
98
+ return (
99
+ float(data.get("parent_cost") or 0),
100
+ float(data.get("subagent_cost") or 0),
101
+ int(data.get("task_count") or 0),
102
+ int(data.get("subagent_count") or 0),
103
+ )
104
+
105
+
106
+ def _fetch_via_neon(*, cycle_id, since, scripts):
107
+ import psycopg2 # noqa: F401
108
+ import db as dbmod
109
+ conn = dbmod.get_conn()
110
+ if cycle_id:
111
+ cur = conn.execute(
112
+ """SELECT COALESCE(SUM(total_cost_usd), 0),
113
+ COALESCE(SUM(subagent_cost_usd), 0),
114
+ COALESCE(SUM(task_call_count), 0),
115
+ COALESCE(SUM(subagent_count), 0)
116
+ FROM claude_sessions
117
+ WHERE cycle_id = %s""",
118
+ [cycle_id],
119
+ )
120
+ else:
121
+ since_ts = datetime.fromtimestamp(since, tz=timezone.utc).isoformat()
122
+ placeholders = ','.join(['%s'] * len(scripts))
123
+ cur = conn.execute(
124
+ f"""SELECT COALESCE(SUM(total_cost_usd), 0),
125
+ COALESCE(SUM(subagent_cost_usd), 0),
126
+ COALESCE(SUM(task_call_count), 0),
127
+ COALESCE(SUM(subagent_count), 0)
128
+ FROM claude_sessions
129
+ WHERE script IN ({placeholders}) AND started_at >= %s""",
130
+ list(scripts) + [since_ts],
131
+ )
132
+ row = cur.fetchone()
133
+ return (
134
+ float(row[0] or 0),
135
+ float(row[1] or 0),
136
+ int(row[2] or 0),
137
+ int(row[3] or 0),
138
+ )
139
+
140
+
112
141
  if __name__ == '__main__':
113
142
  main()
@@ -536,6 +536,117 @@ def format_report(summary, top, bottom, project=None, platform=None,
536
536
  return "\n".join(lines)
537
537
 
538
538
 
539
+ def _apply_top_filter(rows, limit):
540
+ """Anti-pattern filter applied to top-N candidates.
541
+
542
+ PRODUCT_NAMES: hard-drop self-promotional examples regardless of
543
+ clicks (don't teach Claude to namedrop). URL/www. mention: only
544
+ drop when clicks==0 (a URL-bearing post with real human clicks IS
545
+ the gold example by definition; see 2026-05-12 click-aware fix).
546
+ Caller passes overfetched rows; we trim to `limit` after filter.
547
+ """
548
+ clean = []
549
+ for r in rows:
550
+ content = (r[5] or "")
551
+ clicks = r[11] if len(r) > 11 and r[11] is not None else 0
552
+ lower = content.lower()
553
+ if any(name in lower for name in PRODUCT_NAMES):
554
+ continue
555
+ has_url = ("http://" in lower or "https://" in lower or "www." in lower)
556
+ if has_url and clicks == 0:
557
+ continue
558
+ clean.append(r)
559
+ return clean[:limit]
560
+
561
+
562
+ def _fetch_report_via_api(*, platform, project, top, bottom):
563
+ """Pull all 6 SQL aggregations in one call via the v1 route.
564
+
565
+ Returns (summary, style_perf, top_posts, bottom_posts,
566
+ fallback_top|None, top_by_group|None). Row shapes match
567
+ the column order format_post / format_report expect.
568
+ """
569
+ from http_api import api_get
570
+ resp = api_get(
571
+ "/api/v1/posts/top-performers-report",
572
+ query={
573
+ "platform": platform or "",
574
+ "project": project or "",
575
+ "top": str(top),
576
+ "bottom": str(bottom),
577
+ },
578
+ )
579
+ data = (resp or {}).get("data") or {}
580
+ summary = data.get("summary") or []
581
+ style_perf = data.get("style_perf") or []
582
+ raw_top = data.get("top_posts") or []
583
+ raw_bottom = data.get("bottom_posts") or []
584
+ raw_fallback = data.get("fallback_top") or []
585
+ raw_group = data.get("top_by_group") or {}
586
+
587
+ top_filtered = _apply_top_filter(raw_top, top) if raw_top else []
588
+ fallback_filtered = None
589
+ if project and not top_filtered and raw_fallback:
590
+ fallback_filtered = _apply_top_filter(raw_fallback, top)
591
+ top_by_group = None
592
+ if not project:
593
+ top_by_group = {
594
+ proj: _apply_top_filter(rows, 5)
595
+ for proj, rows in raw_group.items()
596
+ }
597
+ return summary, style_perf, top_filtered, raw_bottom, fallback_filtered, top_by_group
598
+
599
+
600
+ def _fetch_report_via_neon(*, platform, project, top, bottom):
601
+ conn = dbmod.get_conn()
602
+ try:
603
+ summary = get_project_platform_summary(conn, project=project, platform=platform)
604
+ style_perf = get_style_performance(conn, platform=platform)
605
+ top_posts = get_top_posts(conn, project=project, platform=platform, limit=top)
606
+ bottom_posts = get_bottom_posts(conn, project=project, platform=platform, limit=bottom)
607
+ fallback_top = None
608
+ if project and not top_posts:
609
+ fallback_top = get_top_posts(conn, project=None, platform=platform, limit=top)
610
+ top_by_group = None
611
+ if not project:
612
+ top_by_group = {}
613
+ min_score = min_score_for(platform)
614
+ platform_filter = "AND platform = %s" if platform else ""
615
+ platform_params = [platform] if platform else []
616
+ cur = conn.execute(
617
+ f"{POSTS_WITH_CLICKS_CTE}"
618
+ f"SELECT DISTINCT COALESCE(project_name, '(no project)') FROM posts_w_clicks "
619
+ f"WHERE status = 'active' AND platform NOT IN ('github_issues') "
620
+ f"AND our_content IS NOT NULL AND LENGTH(our_content) >= %s "
621
+ f"AND upvotes IS NOT NULL AND {SCORE_SQL} >= %s "
622
+ f"{platform_filter} "
623
+ f"ORDER BY 1",
624
+ [MIN_CONTENT_LEN, min_score] + platform_params,
625
+ )
626
+ projects = [row[0] for row in cur.fetchall()]
627
+ for proj in projects:
628
+ proj_filter = proj if proj != "(no project)" else None
629
+ where_extra = "AND project_name = %s" if proj_filter else "AND project_name IS NULL"
630
+ params = ([proj_filter] if proj_filter else []) + platform_params
631
+ cur = conn.execute(
632
+ f"{POSTS_WITH_CLICKS_CTE}"
633
+ f"SELECT id, platform, upvotes, comments_count, views, "
634
+ f"our_content, thread_title, thread_content, "
635
+ f"project_name, posted_at::date, our_account, clicks "
636
+ f"FROM posts_w_clicks WHERE status = 'active' AND {SCORE_SQL} >= {min_score} "
637
+ f"AND our_content IS NOT NULL AND LENGTH(our_content) >= {MIN_CONTENT_LEN} "
638
+ f"AND platform NOT IN ('github_issues') "
639
+ f"{where_extra} {platform_filter} "
640
+ f"ORDER BY {SCORE_SQL} DESC, COALESCE(clicks,0) DESC, "
641
+ f" {UPVOTES_NET_SQL} DESC LIMIT 5",
642
+ params,
643
+ )
644
+ top_by_group[proj] = cur.fetchall()
645
+ return summary, style_perf, top_posts, bottom_posts, fallback_top, top_by_group
646
+ finally:
647
+ conn.close()
648
+
649
+
539
650
  def main():
540
651
  parser = argparse.ArgumentParser(description="Generate top performers feedback report")
541
652
  parser.add_argument("--platform", default=None, help="Filter to specific platform")
@@ -545,71 +656,20 @@ def main():
545
656
  parser.add_argument("--json", action="store_true", help="Output as JSON")
546
657
  args = parser.parse_args()
547
658
 
548
- conn = dbmod.get_conn()
549
-
550
- summary = get_project_platform_summary(conn, project=args.project, platform=args.platform)
551
- style_perf = get_style_performance(conn, platform=args.platform)
552
- top = get_top_posts(conn, project=args.project, platform=args.platform, limit=args.top)
553
- bottom = get_bottom_posts(conn, project=args.project, platform=args.platform, limit=args.bottom)
554
-
555
- # If project was specified but no posts met the threshold, fall back to
556
- # general high-performing posts on the same platform
557
- fallback_top = None
558
- if args.project and not top:
559
- fallback_top = get_top_posts(conn, project=None, platform=args.platform, limit=args.top)
560
-
561
- # When no project filter, also get top 5 per project for focused examples.
562
- # Uses the same SCORE_SQL composite as get_top_posts so ranking is consistent.
563
- top_by_group = None
564
- if not args.project:
565
- top_by_group = {}
566
- min_score = min_score_for(args.platform)
567
- platform_filter = "AND platform = %s" if args.platform else ""
568
- platform_params = [args.platform] if args.platform else []
569
- # Get distinct projects (respecting platform filter). Uses the
570
- # CTE so the SCORE_SQL >= threshold expression resolves `clicks`.
571
- cur = conn.execute(
572
- f"{POSTS_WITH_CLICKS_CTE}"
573
- f"SELECT DISTINCT COALESCE(project_name, '(no project)') FROM posts_w_clicks "
574
- f"WHERE status = 'active' AND platform NOT IN ('github_issues') "
575
- f"AND our_content IS NOT NULL AND LENGTH(our_content) >= %s "
576
- f"AND upvotes IS NOT NULL AND {SCORE_SQL} >= %s "
577
- f"{platform_filter} "
578
- f"ORDER BY 1",
579
- [MIN_CONTENT_LEN, min_score] + platform_params
580
- )
581
- projects = [row[0] for row in cur.fetchall()]
582
- for proj in projects:
583
- proj_filter = proj if proj != "(no project)" else None
584
- where_extra = "AND project_name = %s" if proj_filter else "AND project_name IS NULL"
585
- params = ([proj_filter] if proj_filter else []) + platform_params
586
- # Per-project top-5 examples — these are what Claude actually
587
- # sees as the few-shot prompt. Click DESC tiebreaker means two
588
- # posts at the same composite score sort the higher-click one
589
- # first, putting the better conversion proof in front of Claude.
590
- cur = conn.execute(
591
- f"{POSTS_WITH_CLICKS_CTE}"
592
- f"SELECT id, platform, upvotes, comments_count, views, "
593
- f"our_content, thread_title, thread_content, "
594
- f"project_name, posted_at::date, our_account, clicks "
595
- f"FROM posts_w_clicks WHERE status = 'active' AND {SCORE_SQL} >= {min_score} "
596
- f"AND our_content IS NOT NULL AND LENGTH(our_content) >= {MIN_CONTENT_LEN} "
597
- f"AND platform NOT IN ('github_issues') "
598
- f"{where_extra} {platform_filter} "
599
- f"ORDER BY {SCORE_SQL} DESC, COALESCE(clicks,0) DESC, "
600
- f" {UPVOTES_NET_SQL} DESC LIMIT 5",
601
- params
602
- )
603
- top_by_group[proj] = cur.fetchall()
604
-
605
- conn.close()
659
+ if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
660
+ fetch = _fetch_report_via_neon
661
+ else:
662
+ fetch = _fetch_report_via_api
663
+ summary, style_perf, top, bottom, fallback_top, top_by_group = fetch(
664
+ platform=args.platform, project=args.project, top=args.top, bottom=args.bottom,
665
+ )
606
666
 
607
667
  if args.json:
608
668
  output = {
609
- "summary": [dict(row) for row in summary],
610
- "top_posts": [dict(row) for row in top],
611
- "bottom_posts": [dict(row) for row in bottom],
612
- "fallback_top": [dict(row) for row in fallback_top] if fallback_top else [],
669
+ "summary": [list(row) for row in summary],
670
+ "top_posts": [list(row) for row in top],
671
+ "bottom_posts": [list(row) for row in bottom],
672
+ "fallback_top": [list(row) for row in fallback_top] if fallback_top else [],
613
673
  }
614
674
  print(json.dumps(output, indent=2, default=str))
615
675
  else: