social-autoposter 1.6.25 → 1.6.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.25",
3
+ "version": "1.6.26",
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"
@@ -21,7 +21,18 @@
21
21
  "!scripts/_seo_lane_roi.py",
22
22
  "!scripts/check_improve_runs.py",
23
23
  "!scripts/classify_all_dms.py",
24
- "!scripts/migrate_engagement_styles_to_db.py",
24
+ "!scripts/migrate_*.py",
25
+ "!scripts/_gsc_roi_query.py",
26
+ "!scripts/batch_send_dms.py",
27
+ "!scripts/finalize_post_*.py",
28
+ "!scripts/insert_li_notifs.py",
29
+ "!scripts/li_notif_scan_process.py",
30
+ "!scripts/phase_d_edit.py",
31
+ "!scripts/phase_d_new_comments.py",
32
+ "!scripts/realign_sequences.py",
33
+ "!scripts/scratch_*.py",
34
+ "!scripts/seed_dashboard_users.py",
35
+ "!scripts/send_dashboard_invite.py",
25
36
  "scripts/*.sh",
26
37
  "schema-postgres.sql",
27
38
  "config.example.json",
@@ -30,6 +30,16 @@ from twitter_account import resolve_handle as _resolve_twitter_handle # noqa: E
30
30
  from project_topics import topics_for_project # noqa: E402
31
31
 
32
32
 
33
+ # Freshness window (in hours) for the expire-stale gate that flips stale
34
+ # pending rows to status='expired'. Sourced from the FRESHNESS_HOURS env the
35
+ # cycle exports (run-twitter-cycle.sh) so the expiry ceiling is configured in
36
+ # ONE place. Falls back to 18 when unset (e.g. ad-hoc / --expire-only runs) to
37
+ # preserve the historical default. NOTE: the gate is on discovered_at
38
+ # (discovery age), not tweet_posted_at; for logic D (≤1h discovery freshness)
39
+ # the two are within ~1h of each other.
40
+ EXPIRE_FRESHNESS_HOURS = int(os.environ.get("FRESHNESS_HOURS") or "18")
41
+
42
+
33
43
  # Real Twitter snowflake IDs are 18-19 digit numbers with full entropy in the
34
44
  # low bits (sequence counter + worker/datacenter ID = bottom 22 bits ≈ bottom
35
45
  # 7 decimal digits). An ID ending in 6+ zeros is statistically impossible
@@ -505,9 +515,10 @@ def upsert_candidates(tweets, config, batch_id=None, attempts_map=None, scored_s
505
515
  print(f" Error inserting {url}: {e}", file=sys.stderr)
506
516
  continue
507
517
 
508
- # Expire old pending candidates (> 18h). This is a freshness GATE
509
- # (status flip), not a delete — we keep the row forever for analytics.
510
- api_post("/api/v1/twitter-candidates/expire-stale", {"freshness_hours": 18})
518
+ # Expire old pending candidates past the freshness window. This is a
519
+ # freshness GATE (status flip), not a delete — we keep the row forever
520
+ # for analytics.
521
+ api_post("/api/v1/twitter-candidates/expire-stale", {"freshness_hours": EXPIRE_FRESHNESS_HOURS})
511
522
 
512
523
  # NO PRUNING. We keep every twitter_candidates row forever (chosen, skipped,
513
524
  # expired) so we can audit project routing, skip reasons, growth dynamics,
@@ -570,7 +581,7 @@ def main():
570
581
  # it off and prints the count.
571
582
  resp = api_post(
572
583
  "/api/v1/twitter-candidates/expire-stale",
573
- {"freshness_hours": 18},
584
+ {"freshness_hours": EXPIRE_FRESHNESS_HOURS},
574
585
  )
575
586
  expired = (resp.get("data") or {}).get("expired_count", 0)
576
587
  print(f"Expired {expired} old pending candidates (no row deletion)")
@@ -227,3 +227,65 @@ defer_if_foreign_for_backend() {
227
227
  # the twitter-agent profile. Always return 1 (do not defer).
228
228
  return 1
229
229
  }
230
+
231
+ # --- browser-harness `-c` capability self-heal (added 2026-06-02) -----------
232
+ # A stale ~/Developer/browser-harness checkout that PREDATES the `-c` interface
233
+ # makes `browser-harness -c "<script>"` print its usage string instead of
234
+ # running the script. The Phase 1 scan loop in run-twitter-cycle.sh then yields
235
+ # zero tweets with no obvious cause. cli.js documents the same failure for the
236
+ # bh_run MCP path. When this bit the testing machine, the debugging agent saw
237
+ # the `-c` flag, WRONGLY assumed it was unsupported, and proposed rewriting the
238
+ # call to a nonexistent "stdin form" (browser-harness has no stdin mode — `-c`
239
+ # is the only interface; see run.py). This runs at source-time, before any
240
+ # `-c` call, so all twitter harness scripts (cycle/threads/engage/dm/followups)
241
+ # get auto-repair. Static probe is one grep when fresh (zero steady-state cost);
242
+ # the git+uv refresh only fires when the checkout is actually stale.
243
+ _sa_harness_log() {
244
+ # Use the caller's log() FUNCTION when present; `declare -F` matches only a
245
+ # shell function, never the macOS /usr/bin/log binary (command -v would).
246
+ if declare -F log >/dev/null 2>&1; then log "$*"; else echo "[$(date +%H:%M:%S)] $*" >&2; fi
247
+ }
248
+ _sa_resolve_uv() {
249
+ local c
250
+ c="$(command -v uv 2>/dev/null)" && { echo "$c"; return 0; }
251
+ for c in "$HOME/.local/bin/uv" /opt/homebrew/bin/uv /usr/local/bin/uv; do
252
+ [ -x "$c" ] && { echo "$c"; return 0; }
253
+ done
254
+ return 1
255
+ }
256
+ ensure_harness_c_support() {
257
+ # Run once per process even if several libs source this file.
258
+ [ -n "${_SA_HARNESS_C_CHECKED:-}" ] && return 0
259
+ export _SA_HARNESS_C_CHECKED=1
260
+
261
+ local src="$HOME/Developer/browser-harness"
262
+ local run_py="$src/src/browser_harness/run.py"
263
+ local bh="$HOME/.local/bin/browser-harness"
264
+
265
+ # Static capability probe — no daemon/Chrome needed. The CLI is an editable
266
+ # (`uv tool install -e`) install of $src, so its run.py is the source of
267
+ # truth for whether `-c` is recognized.
268
+ if [ -f "$run_py" ] && grep -q '"-c"' "$run_py"; then
269
+ return 0
270
+ fi
271
+
272
+ _sa_harness_log "[harness] browser-harness checkout missing/stale (no -c support) -> self-healing via git + uv..."
273
+ if [ -d "$src/.git" ]; then
274
+ git -C "$src" fetch --depth 1 origin HEAD >/dev/null 2>&1 \
275
+ && git -C "$src" reset --hard FETCH_HEAD >/dev/null 2>&1
276
+ fi
277
+ local uv; uv="$(_sa_resolve_uv || true)"
278
+ if [ -n "$uv" ] && [ -d "$src" ]; then
279
+ "$uv" tool install --force -e "$src" >/dev/null 2>&1 || true
280
+ fi
281
+ [ -x "$bh" ] && "$bh" --reload >/dev/null 2>&1 || true
282
+
283
+ if [ -f "$run_py" ] && grep -q '"-c"' "$run_py"; then
284
+ _sa_harness_log "[harness] self-heal OK -> browser-harness -c is supported now"
285
+ return 0
286
+ fi
287
+ _sa_harness_log "[harness] ERROR: browser-harness still lacks -c after self-heal."
288
+ _sa_harness_log "[harness] FIX: run 'social-autoposter update' (re-clones/refreshes $src + reinstalls the CLI). The -c flag is CORRECT; browser-harness has NO stdin mode, so do NOT rewrite the cycle to a stdin form."
289
+ return 1
290
+ }
291
+ ensure_harness_c_support || true
@@ -147,7 +147,9 @@ FRESHNESS_HOURS=2
147
147
  # since-rewrite hook) stays tightened to 1h, the winning D setting.
148
148
  TWITTER_CYCLE_VARIANT=D
149
149
  FRESHNESS_HOURS_DISCOVER=1
150
- export TWITTER_CYCLE_VARIANT FRESHNESS_HOURS_DISCOVER
150
+ # Export FRESHNESS_HOURS too so score_twitter_candidates.py inherits it and
151
+ # drives the expire-stale gate from the same knob (was hardcoded 18h there).
152
+ export TWITTER_CYCLE_VARIANT FRESHNESS_HOURS_DISCOVER FRESHNESS_HOURS
151
153
  # Hook env: ~/.claude/hooks/twitter-search-since-rewrite.py reads this and
152
154
  # uses it in place of its hardcoded 6h default when present.
153
155
  export FRESHNESS_HOURS_OVERRIDE=$FRESHNESS_HOURS_DISCOVER
@@ -1,231 +0,0 @@
1
- #!/usr/bin/env python3
2
- """One-off: GSC pipeline ROI per project (last 30d).
3
-
4
- For each project in config.json with GSC-pipeline pages in the last 30 days,
5
- queries PostHog (HogQL) for total unique-distinct_id visitors to those exact
6
- $pathname values, and the top-3 pages by visitors. Joins with Postgres for
7
- GSC clicks and spend.
8
-
9
- Run with:
10
- /opt/homebrew/bin/python3.11 scripts/_gsc_roi_query.py
11
- """
12
- import json
13
- import os
14
- import subprocess
15
- import sys
16
- import urllib.parse
17
- import urllib.request
18
- from collections import defaultdict
19
-
20
- ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
-
22
-
23
- def load_env():
24
- with open(os.path.join(ROOT, '.env')) as f:
25
- for line in f:
26
- line = line.strip()
27
- if line and not line.startswith('#') and '=' in line:
28
- k, v = line.split('=', 1)
29
- os.environ.setdefault(k.strip(), v.strip())
30
-
31
-
32
- def ph_key():
33
- return subprocess.check_output(
34
- ['security', 'find-generic-password', '-s', 'PostHog-Personal-API-Key-m13v', '-w'],
35
- text=True,
36
- ).strip()
37
-
38
-
39
- def hogql(project_id: str, sql: str, key: str):
40
- body = json.dumps({"query": {"kind": "HogQLQuery", "query": sql}}).encode()
41
- req = urllib.request.Request(
42
- f"https://us.posthog.com/api/projects/{project_id}/query/",
43
- data=body,
44
- headers={
45
- "Authorization": f"Bearer {key}",
46
- "Content-Type": "application/json",
47
- },
48
- method="POST",
49
- )
50
- with urllib.request.urlopen(req, timeout=60) as r:
51
- return json.loads(r.read().decode())
52
-
53
-
54
- def main():
55
- load_env()
56
- import psycopg2
57
-
58
- # Build project -> posthog project_id map from config.json
59
- with open(os.path.join(ROOT, 'config.json')) as f:
60
- cfg = json.load(f)
61
- posthog_map = {}
62
- site_map = {}
63
- for p in cfg['projects']:
64
- name = p['name']
65
- ph = (p.get('posthog') or {}).get('project_id')
66
- if ph:
67
- posthog_map[name.lower()] = str(ph)
68
- site_map[name.lower()] = (p.get('landing_pages') or {}).get('base_url') or p.get('website') or ''
69
-
70
- # Pull GSC pages + spend per project (last 30d)
71
- conn = psycopg2.connect(os.environ['DATABASE_URL'])
72
- cur = conn.cursor()
73
- cur.execute("""
74
- SELECT gq.product, gq.page_url, gq.clicks, gq.impressions,
75
- COALESCE(cs.total_cost_usd, 0)::float AS cost
76
- FROM gsc_queries gq
77
- LEFT JOIN claude_sessions cs ON cs.session_id = gq.claude_session_id
78
- WHERE gq.status='done'
79
- AND gq.completed_at >= NOW() - INTERVAL '30 days'
80
- AND gq.page_url IS NOT NULL AND gq.page_url <> ''
81
- """)
82
- rows = cur.fetchall()
83
- cur.close(); conn.close()
84
-
85
- # Group by product
86
- per_project = defaultdict(list)
87
- for product, url, clicks, impr, cost in rows:
88
- # Normalize: if URL is relative, leave path; if absolute, parse path
89
- if url.startswith('http'):
90
- u = urllib.parse.urlparse(url)
91
- host = u.netloc
92
- path = u.path or '/'
93
- else:
94
- host = ''
95
- path = url
96
- per_project[product].append({
97
- "host": host, "path": path, "clicks": int(clicks or 0),
98
- "impressions": int(impr or 0), "cost": float(cost or 0),
99
- })
100
-
101
- key = ph_key()
102
- summary = []
103
-
104
- for product, pages in per_project.items():
105
- ph_proj = posthog_map.get(product.lower())
106
- n_pages = len(pages)
107
- spend = sum(p['cost'] for p in pages)
108
- gsc_clicks = sum(p['clicks'] for p in pages)
109
- gsc_impr = sum(p['impressions'] for p in pages)
110
-
111
- if not ph_proj:
112
- summary.append({
113
- "product": product, "n_pages": n_pages, "spend": spend,
114
- "visitors": None, "gsc_clicks": gsc_clicks, "gsc_impr": gsc_impr,
115
- "top": [], "note": "no posthog project_id in config.json",
116
- })
117
- continue
118
-
119
- # PostHog HogQL: count unique distinct_id per $pathname for the matching paths.
120
- # Filter by path IN (...) — host doesn't matter inside a single PostHog project.
121
- paths = sorted({p['path'] for p in pages if p['path']})
122
- if not paths:
123
- summary.append({
124
- "product": product, "n_pages": n_pages, "spend": spend,
125
- "visitors": 0, "gsc_clicks": gsc_clicks, "gsc_impr": gsc_impr,
126
- "top": [], "note": "no paths to query",
127
- })
128
- continue
129
- path_list = ", ".join(f"'{p.replace(chr(39), chr(39)+chr(39))}'" for p in paths)
130
-
131
- sql = (
132
- "SELECT properties.$pathname AS path, count(DISTINCT distinct_id) AS visitors "
133
- "FROM events "
134
- "WHERE event = '$pageview' "
135
- f"AND timestamp > now() - INTERVAL 30 DAY "
136
- f"AND properties.$pathname IN ({path_list}) "
137
- "GROUP BY properties.$pathname "
138
- "ORDER BY visitors DESC "
139
- "LIMIT 500"
140
- )
141
- try:
142
- res = hogql(ph_proj, sql, key)
143
- results = res.get('results') or []
144
- total_visitors = sum(r[1] for r in results)
145
- top = [(r[0], r[1]) for r in results[:5]]
146
- except Exception as e:
147
- total_visitors = None
148
- top = []
149
- err = str(e)[:120]
150
- summary.append({
151
- "product": product, "n_pages": n_pages, "spend": spend,
152
- "visitors": None, "gsc_clicks": gsc_clicks, "gsc_impr": gsc_impr,
153
- "top": [], "note": f"PostHog error: {err}",
154
- })
155
- continue
156
-
157
- summary.append({
158
- "product": product, "n_pages": n_pages, "spend": spend,
159
- "visitors": total_visitors, "gsc_clicks": gsc_clicks, "gsc_impr": gsc_impr,
160
- "top": top, "ph_proj": ph_proj, "note": "",
161
- })
162
-
163
- # Sort by visitors desc (None at bottom)
164
- summary.sort(key=lambda s: (s['visitors'] is None, -(s['visitors'] or 0)))
165
-
166
- print()
167
- print(f"{'Project':<14} {'Pages':>5} {'Spend':>9} {'Visitors':>9} {'GSCclicks':>9} {'GSCimpr':>8} {'$/visitor':>10} Top page (visitors)")
168
- print("-" * 130)
169
- for s in summary:
170
- v = s['visitors']
171
- vs = "n/a" if v is None else f"{v:,}"
172
- cpv = "n/a" if not v else f"${s['spend']/v:.2f}"
173
- top1 = s['top'][0] if s['top'] else ("", 0)
174
- top1_str = f"{top1[0][:60]} ({top1[1]})" if top1[0] else "-"
175
- print(f"{s['product']:<14} {s['n_pages']:>5} ${s['spend']:>7.0f} {vs:>9} {s['gsc_clicks']:>9} {s['gsc_impr']:>8} {cpv:>10} {top1_str}")
176
- if s['note']:
177
- print(f" note: {s['note']}")
178
-
179
- print("\n\nTOP-3 GSC PAGES PER PROJECT BY POSTHOG VISITORS:")
180
- print("=" * 100)
181
- for s in summary:
182
- if not s['top']:
183
- continue
184
- print(f"\n{s['product']} (ph={s.get('ph_proj','?')}):")
185
- for path, v in s['top'][:3]:
186
- print(f" {v:>6,} {path}")
187
-
188
- # Now for the top-N pages overall across all projects, break down by $referrer / utm_source
189
- print("\n\nTRAFFIC SOURCE BREAKDOWN (Top 5 GSC pages by visitors, per project):")
190
- print("=" * 100)
191
- for s in summary:
192
- if not s['top']:
193
- continue
194
- ph_proj = s.get('ph_proj')
195
- if not ph_proj:
196
- continue
197
- top_paths = [p for p, _ in s['top'][:5]]
198
- path_list = ", ".join(f"'{p.replace(chr(39), chr(39)+chr(39))}'" for p in top_paths)
199
- sql = (
200
- "SELECT properties.$pathname AS path, "
201
- "coalesce(properties.$referring_domain, 'direct/none') AS ref, "
202
- "count(DISTINCT distinct_id) AS visitors "
203
- "FROM events "
204
- "WHERE event = '$pageview' "
205
- "AND timestamp > now() - INTERVAL 30 DAY "
206
- f"AND properties.$pathname IN ({path_list}) "
207
- "GROUP BY path, ref "
208
- "ORDER BY path, visitors DESC "
209
- "LIMIT 200"
210
- )
211
- try:
212
- r = hogql(ph_proj, sql, key)
213
- res = r.get('results') or []
214
- except Exception as e:
215
- print(f"\n{s['product']}: PostHog source-breakdown error: {str(e)[:80]}")
216
- continue
217
- by_path = defaultdict(list)
218
- for path, ref, v in res:
219
- by_path[path].append((ref, v))
220
- print(f"\n{s['product']}:")
221
- for path in top_paths:
222
- srcs = by_path.get(path) or []
223
- srcs.sort(key=lambda x: -x[1])
224
- total = sum(v for _, v in srcs)
225
- head = ", ".join(f"{ref}={v}" for ref, v in srcs[:5])
226
- print(f" [{total:>5,}] {path}")
227
- print(f" {head}")
228
-
229
-
230
- if __name__ == "__main__":
231
- main()
@@ -1,148 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Batch send Reddit DMs with retries and DB updates."""
3
-
4
- import json
5
- import os
6
- import subprocess
7
- import sys
8
- import time
9
-
10
- REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11
- sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
12
- from db import load_env
13
-
14
- load_env()
15
- DB_URL = os.environ["DATABASE_URL"]
16
-
17
- DMS = [
18
- (675, "RobotEnthusiast1980", "glass break sensor testing", "did you get around to testing the placement yet? curious how the 7m spec holds up in your actual space"),
19
- (676, "EverySecondCountss", "zed + mcp daily driver", "did you end up getting a stable daily driver setup with zed or still tweaking configs? genuinely want to try it but don't want to spend a week debugging glue"),
20
- (677, "Worf-", "NVR software choice", "solid plan on the axis cameras. what NVR software are you leaning toward? if you go with something like frigate the used ones should just work over onvif"),
21
- (678, "raja-rancho", "good automation channels", "what videos or channels were most useful when you were starting out with automations? always looking for good recs to share"),
22
- (679, "ShermanHoax", "sysco alternatives", "once that pricing creep sets in its brutal. have you found any regional distributors worth working with as a backup?"),
23
- (680, "Anonymously_Joe", "cold ferment specs", "cold ferment is the move for volume. how long are you fermenting and what hydration? always curious about other shops setups"),
24
- (681, "Lost_Drunken_Sailor", "cutting phone orders", "have you actually seen places pull off online only without losing the older regulars? curious if the drop off is as bad as i imagine"),
25
- (682, "rocketstart1", "tool integration gaps", "the human glue thing is exactly the problem. which tools are you using day to day? curious if anything covers the custom integrations zapier cant handle"),
26
- (683, "ghl92479", "AST based test gen", "the AST based test generation is clever. what parser are you using for the tree, or just feeding file structure to the agent directly?"),
27
- (684, "Other_Grocery_9829", "alternatives to stealth", "since you mentioned puppeteer stealth, what are you actually automating? curious if theres a native API approach that could skip the detection game"),
28
- (685, "Hot-Confection-3459", "liquid ai in production", "lol the trade secrets slip was great. seriously though are you using liquid models in production? curious how they compare for coding tasks"),
29
- (686, "Triple-F-Jeff", "lorex web interface fix", "did you get the web interface working? accessing cameras directly by IP usually bypasses the NVR firmware limitations"),
30
- (687, "socialmatehq", "supabase affiliate tracking", "your supabase CRM setup sounds clean. curious about the affiliate tracking, did you build custom tables or find an existing schema?"),
31
- (688, "Zatetics", "corrections file approach", "the skills and memory approach you described is exactly my workflow too. what does your corrections file look like, do you append or prune old entries?"),
32
- (689, "partstable", "corrections database ROI", "800 tokens preventing 90% of mistakes is incredible ROI. how do you decide when a correction gets promoted vs retired?"),
33
- (690, "stumptowndoug", "multi agent file conflicts", "nice work shipping the fs watcher. how does it handle when multiple agents write the same file? thats where my polling setup kept breaking"),
34
- (691, "Virtual_Armadillo126", "perception vs permissions", "your point about what agents can see vs do is underrated. have you seen any framework that cleanly separates perception from permission boundaries?"),
35
- (692, "Limp_Cauliflower5192", "wildest user edge case", "first week after shipping is always humbling. what was the wildest edge case a real user found in your agent?"),
36
- (693, "Intelligent-School64", "batch processing technique", "going from 90s to 15s latency is massive. would love to hear more about the batch processing approach you landed on"),
37
- (694, "chocboyfish", "lighting upgrade idea", "did the lighting swap idea resonate? even going from overhead fluorescent to warmer pendants can change the whole vibe for cheap"),
38
- (695, "Individual-Love-9342", "per agent cost tracking", "the postgres logging approach has been solid for us. what granularity are you tracking, per call or per task?"),
39
- (696, "symmetry_seeking", "doc drift detection", "hierarchy docs drifting from code is the exact problem. have you tried any automated checks to flag when docs and code diverge?"),
40
- (697, "commanderjack_EDH", "camera ONVIF check", "did you check the model number yet? if they do ONVIF you have tons of options for third party NVR software even without the app"),
41
- (698, "Silver_Ad4449", "tiktok content approach", "smart move going deep on one platform. what kind of content is working for you on tiktok? the algorithm rewards very different stuff"),
42
- (699, "upvotes2doge", "ARIA tree on different sites", "curious if youve tried inspector jake on SPAs specifically, some seem to have pretty sparse accessibility trees which limits what the agent can actually target"),
43
- (700, "Abject_Elevator5461", "online ordering shift rate", "the ATP difference between phone and online is legit. curious what percentage of orders you see shift when places push for online"),
44
- (701, "JimiJohhnySRV", "hallway camera setup", "the activity zone trick for high traffic areas is a game changer. what camera did you end up using for the hallway?"),
45
- (702, "handsnerfin", "outline before AI approach", "the outline before asking approach really does change retention. curious if youve found specific problem types where it helps more"),
46
- (703, "Objective_River_5218", "global skill noise", "interesting that global skills havent gotten noisy for you yet. how many are in your knowledge base right now?"),
47
- (704, "bobo-the-merciful", "nelson quarterdeck rhythm", "the admiral pattern is well thought out. does the quarterdeck rhythm catch things the escalation path misses in practice?"),
48
- (705, "MrBrightside_119", "stuck session detection", "the cron based stuck detection is clever. whats your time threshold and how often does it false positive?"),
49
- (706, "Pritom14", "AX tree token savings", "landed on the same AX tree plus screenshot combo. have you benchmarked token savings with tree data vs pure screenshots through your proxy?"),
50
- (707, "EbbCommon9300", "autonomy session risk", "the near miss data angle is fascinating. are you scoping risk per agent session or across delegated action chains?"),
51
- (708, "Beautiful_Ad789", "workflow format", "totally agree vibe workflow is its own thing. what format did you end up using instead of json for workflow definitions?"),
52
- (709, "eazyigz123", "hard block rollback rate", "the hard block vs soft memory distinction is interesting. how often have you had to roll blocks back when context changed?"),
53
- (710, "entheosoul", "MCP structured output case", "fair pushback on MCP vs bash. the structured output case is where MCP shines but agreed its not always worth the overhead"),
54
- (711, "dawsonvpowell", "Jobber vs Housecall gaps", "everything in one system makes sense. which of those tools did you go with and where were the gaps?"),
55
- (712, "KiteWhisperer", "clinic manager tipping point", "thats the goal for year two. what patient volume did you find makes a clinic manager pay for themselves?"),
56
- (713, "Ambitious_Bridge_180", "other OD communities", "just checked out ODs on Finance, great group. any other communities youd recommend for the business ops side?"),
57
- (714, "EthanWalker483", "lead building fix", "monday morning reminder emails are exactly it. did switching to a database fix the lead building or just shift the bottleneck?"),
58
- ]
59
-
60
- def send_dm(dm_id, author, subject, body):
61
- max_retries = 3
62
- for attempt in range(1, max_retries + 1):
63
- print(f"[{dm_id}] Attempt {attempt}: Sending to {author}...")
64
- try:
65
- result = subprocess.run(
66
- ["python3", os.path.join(REPO_DIR, "scripts", "reddit_browser.py"),
67
- "compose-dm", author, subject, body],
68
- capture_output=True, text=True, timeout=60
69
- )
70
- data = json.loads(result.stdout.strip().split('\n')[-1] if result.stdout.strip() else '{}')
71
- except Exception as e:
72
- data = {"ok": False, "error": str(e)}
73
-
74
- if data.get("ok"):
75
- print(f"[{dm_id}] SUCCESS: Sent to {author}")
76
- # Update DB
77
- try:
78
- subprocess.run(
79
- ["psql", DB_URL, "-c",
80
- f"UPDATE dms SET status='sent', our_dm_content=$${body}$$, sent_at=NOW() WHERE id={dm_id};"],
81
- capture_output=True, timeout=15
82
- )
83
- except Exception:
84
- pass
85
- # Log outbound
86
- try:
87
- subprocess.run(
88
- ["python3", os.path.join(REPO_DIR, "scripts", "dm_conversation.py"),
89
- "log-outbound", "--dm-id", str(dm_id), "--content", body],
90
- capture_output=True, timeout=15
91
- )
92
- except Exception:
93
- pass
94
- # Stamp chat_url from the sender's returned thread_url so the dashboard
95
- # "open chat" button points at the actual room. set-url refuses anything
96
- # that isn't a real DM-thread URL, so bad data can't leak in.
97
- thread_url = data.get("thread_url") or ""
98
- if thread_url:
99
- try:
100
- subprocess.run(
101
- ["python3", os.path.join(REPO_DIR, "scripts", "dm_conversation.py"),
102
- "set-url", "--dm-id", str(dm_id), "--url", thread_url],
103
- capture_output=True, timeout=15
104
- )
105
- except Exception:
106
- pass
107
- return True
108
-
109
- error = data.get("error", "unknown")
110
- print(f"[{dm_id}] FAILED (attempt {attempt}): {error}")
111
- if attempt < max_retries:
112
- print(f"[{dm_id}] Waiting 30s before retry...")
113
- time.sleep(30)
114
-
115
- # All retries exhausted
116
- print(f"[{dm_id}] ERROR: All retries exhausted for {author}")
117
- try:
118
- subprocess.run(
119
- ["psql", DB_URL, "-c",
120
- f"UPDATE dms SET status='error', skip_reason=$$Failed after 3 attempts: {error}$$ WHERE id={dm_id};"],
121
- capture_output=True, timeout=15
122
- )
123
- except Exception:
124
- pass
125
- return False
126
-
127
-
128
- if __name__ == "__main__":
129
- # Force unbuffered output
130
- sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', buffering=1)
131
- sys.stderr = os.fdopen(sys.stderr.fileno(), 'w', buffering=1)
132
-
133
- succeeded = 0
134
- failed = 0
135
- for dm_id, author, subject, body in DMS:
136
- if send_dm(dm_id, author, subject, body):
137
- succeeded += 1
138
- else:
139
- failed += 1
140
- sys.stdout.flush()
141
- # Small delay between DMs
142
- time.sleep(2)
143
-
144
- print(f"\n=== SUMMARY ===")
145
- print(f"Succeeded: {succeeded}")
146
- print(f"Failed: {failed}")
147
- print(f"Total: {succeeded + failed}")
148
- sys.stdout.flush()