@m13v/s4l 1.6.197-rc.10

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.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
package/skill/stats.sh ADDED
@@ -0,0 +1,521 @@
1
+ #!/usr/bin/env bash
2
+ # stats.sh — Full stats pipeline:
3
+ # Step 1: Reddit profile scrape (headless Playwright, views + upvotes + comments_count)
4
+ # Step 2: API stats (deletion/removal detection + stats fallback) via Python
5
+ # Step 3: X/Twitter stats via Claude + Playwright (browser required)
6
+ # Step 4: LinkedIn stats via Claude + Playwright (browser required)
7
+ # Called by launchd every 6 hours.
8
+ #
9
+ # Args (any order):
10
+ # --platform <reddit|twitter|linkedin|moltbook> Run only the steps for one platform.
11
+ # --quiet Minimal Python output.
12
+ # If --platform is omitted, all steps run (backward-compatible default).
13
+
14
+ set -uo pipefail
15
+
16
+ # Portable platform helpers (defines gtimeout shim for Linux). This is sourced
17
+ # early so the `gtimeout` function is available. Note: platform.sh exports a
18
+ # variable also named PLATFORM (darwin/linux), which stats.sh's arg parser
19
+ # immediately overwrites with the social-platform name below; that is fine
20
+ # because stats.sh never calls stat_mtime/platform_notify after arg parsing.
21
+ # shellcheck source=/dev/null
22
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/platform.sh"
23
+
24
+ REPO_DIR="$HOME/social-autoposter"
25
+ SKILL_FILE="$REPO_DIR/SKILL.md"
26
+ LOG_DIR="$REPO_DIR/skill/logs"
27
+
28
+ # shellcheck source=/dev/null
29
+ source "$(dirname "${BASH_SOURCE[0]}")/lock.sh"
30
+
31
+ # Parse args (support --platform <name> and --quiet in any order).
32
+ QUIET=""
33
+ PLATFORM=""
34
+ while [ $# -gt 0 ]; do
35
+ case "$1" in
36
+ --platform)
37
+ PLATFORM="${2:-}"
38
+ shift 2
39
+ ;;
40
+ --platform=*)
41
+ PLATFORM="${1#--platform=}"
42
+ shift
43
+ ;;
44
+ --quiet)
45
+ QUIET="--quiet"
46
+ shift
47
+ ;;
48
+ *)
49
+ # Unknown arg: ignore (keeps backward compatibility with callers).
50
+ shift
51
+ ;;
52
+ esac
53
+ done
54
+
55
+ # Validate --platform if provided.
56
+ case "$PLATFORM" in
57
+ ""|reddit|twitter|linkedin|moltbook)
58
+ ;;
59
+ *)
60
+ echo "stats.sh: invalid --platform '$PLATFORM' (expected reddit, twitter, linkedin, or moltbook)" >&2
61
+ exit 2
62
+ ;;
63
+ esac
64
+
65
+ # Decide which steps to run.
66
+ # Variable naming: RUN_STEP1 = Reddit profile scrape, RUN_STEP2 = API stats.
67
+ # No --platform means "all" (legacy behavior, kept for manual invocations).
68
+ if [ -z "$PLATFORM" ]; then
69
+ RUN_STEP1=1; RUN_STEP2=1; RUN_STEP3=1; RUN_STEP4=1
70
+ else
71
+ # Per-platform mode: default everything off, then enable per platform.
72
+ RUN_STEP1=0; RUN_STEP2=0; RUN_STEP3=0; RUN_STEP4=0
73
+ case "$PLATFORM" in
74
+ reddit) RUN_STEP1=1; RUN_STEP2=1 ;; # scrape then API.
75
+ twitter) RUN_STEP3=1 ;; # Step 3 handles Twitter API directly.
76
+ linkedin) RUN_STEP4=1 ;; # LinkedIn has no cheap API leg.
77
+ moltbook) RUN_STEP2=1 ;; # API-only, covered by Step 2.
78
+ esac
79
+ fi
80
+
81
+ # Load secrets (MOLTBOOK_API_KEY, DATABASE_URL, etc.)
82
+ # shellcheck source=/dev/null
83
+ [ -f "$REPO_DIR/.env" ] && source "$REPO_DIR/.env"
84
+
85
+ mkdir -p "$LOG_DIR"
86
+ # Include platform in log filename so the dashboard can distinguish per-platform runs.
87
+ LOG_TAG="${PLATFORM:-all}"
88
+ LOGFILE="$LOG_DIR/stats-${LOG_TAG}-$(date +%Y-%m-%d_%H%M%S).log"
89
+
90
+ log() { echo "[$(date +%H:%M:%S)] $*" >> "$LOGFILE"; echo "[$(date +%H:%M:%S)] $*"; }
91
+
92
+ RUN_START=$(date +%s)
93
+ STEP1_EXIT=0; STEP2_EXIT=0; STEP3_EXIT=0; STEP4_EXIT=0
94
+
95
+ log "=== Stats Pipeline Run: $(date) ==="
96
+ if [ -n "$PLATFORM" ]; then
97
+ log "Platform filter: $PLATFORM (step1=$RUN_STEP1 step2=$RUN_STEP2 step3=$RUN_STEP3 step4=$RUN_STEP4)"
98
+ else
99
+ log "Platform filter: (none, running all steps)"
100
+ fi
101
+
102
+ # ═══════════════════════════════════════════════════════
103
+ # STEP 1: Reddit profile scrape (headless Playwright, no Claude session).
104
+ # Runs BEFORE Step 2 so thread + comment rows get views/upvotes/comments_count
105
+ # in a single no-API pass. Step 2 then skips rows refreshed within the last 4h
106
+ # and spends the API budget only on deletion detection + unmatched rows.
107
+ # ═══════════════════════════════════════════════════════
108
+ if [ "$RUN_STEP1" -eq 1 ]; then
109
+ log "Step 1: Reddit profile scrape (headless Playwright)"
110
+
111
+ # Serialize with other reddit-agent consumers (post_reddit, run-reddit-threads,
112
+ # engage-dm-replies, audit-reddit*). Unified Python lease (2026-05-10) —
113
+ # TTL-aware, MCP-proxy heartbeated during reddit-agent calls.
114
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 3600 --ttl 90 2>&1 || \
115
+ log "WARNING: reddit_browser_lock.py acquire failed; proceeding without lease."
116
+
117
+ REDDIT_USERNAME=$(python3 -c "import json; print(json.load(open('$REPO_DIR/config.json'))['accounts']['reddit']['username'])" 2>/dev/null || echo "")
118
+
119
+ if [ -n "$REDDIT_USERNAME" ]; then
120
+ SCRAPE_OUT=$(mktemp)
121
+ gtimeout 900 python3 "$REPO_DIR/scripts/reddit_browser.py" scrape-views "$REDDIT_USERNAME" 300 > "$SCRAPE_OUT" 2>> "$LOGFILE"
122
+ STEP1_EXIT=$?
123
+ if [ "$STEP1_EXIT" -eq 124 ]; then
124
+ log "Step 1: TIMEOUT (15 min limit reached)"
125
+ rm -f "$SCRAPE_OUT"
126
+ elif [ "$STEP1_EXIT" -ne 0 ]; then
127
+ log "Step 1: FAILED scrape-views (exit $STEP1_EXIT)"
128
+ head -c 500 "$SCRAPE_OUT" >> "$LOGFILE" 2>/dev/null || true
129
+ rm -f "$SCRAPE_OUT"
130
+ else
131
+ # Extract the .results array into the format scrape_reddit_views.py expects.
132
+ python3 -c "
133
+ import json, sys
134
+ with open('$SCRAPE_OUT') as f:
135
+ data = json.load(f)
136
+ if not data.get('ok'):
137
+ print('scrape_views returned ok=false:', data.get('error', 'unknown'), file=sys.stderr)
138
+ sys.exit(2)
139
+ with open('/tmp/reddit_views.json', 'w') as f:
140
+ json.dump(data.get('results', []), f)
141
+ print(f\"scraped {data.get('total', 0)} urls, {data.get('with_views', 0)} with views, {data.get('with_score', 0)} with score, {data.get('with_comments_count', 0)} with comments_count\")
142
+ " >> "$LOGFILE" 2>&1
143
+ EXTRACT_EXIT=$?
144
+ rm -f "$SCRAPE_OUT"
145
+ if [ "$EXTRACT_EXIT" -ne 0 ]; then
146
+ log "Step 1: FAILED extract (exit $EXTRACT_EXIT)"
147
+ else
148
+ python3 "$REPO_DIR/scripts/scrape_reddit_views.py" --from-json /tmp/reddit_views.json $QUIET >> "$LOGFILE" 2>&1
149
+ UPDATE_EXIT=$?
150
+ if [ "$UPDATE_EXIT" -ne 0 ]; then
151
+ log "Step 1: FAILED DB update (exit $UPDATE_EXIT)"
152
+ else
153
+ log "Step 1: Done"
154
+ fi
155
+ fi
156
+ fi
157
+ else
158
+ log "Step 1: SKIPPED, no Reddit username in config.json"
159
+ fi
160
+ # Release the reddit-browser lock NOW. Step 2 (stats.py --reddit-only)
161
+ # is pure unauthenticated HTTPS to old.reddit.com/api/info.json: no Playwright,
162
+ # no logged-in session, different rate-limit bucket from reddit-agent. Holding
163
+ # the lock through Step 2's paced API loop (~100 req / 10 min) starves the
164
+ # post_reddit + run-reddit-search + dm-replies queue for 5-15 min every cycle.
165
+ # Releasing here keeps Step 1's serialization guarantee for the actual browser
166
+ # work and frees the queue immediately.
167
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
168
+ log "Step 1: released reddit-browser lease (Step 2 is HTTP-only)"
169
+ else
170
+ log "Step 1: SKIPPED (platform=$PLATFORM)"
171
+ fi
172
+
173
+ # ═══════════════════════════════════════════════════════
174
+ # STEP 2: API stats — deletion/removal detection and stats fallback for any
175
+ # row Step 1 couldn't cover. Rows refreshed by Step 1 within the last 4h
176
+ # are skipped via the engagement_updated_at freshness window.
177
+ # ═══════════════════════════════════════════════════════
178
+ # Sidecar JSON written by stats.py --reply-summary so we can forward
179
+ # the per-platform reply-refresh count to log_run.py at the end of the run.
180
+ # The Python side writes {reddit, twitter, github} integers (zeros if a
181
+ # platform's reply pass didn't run).
182
+ REPLY_SUMMARY_FILE=$(mktemp -t fazm-reply-summary.XXXXXX)
183
+ # Sidecar JSON written by scrape_linkedin_stats.py --summary so we can forward
184
+ # LinkedIn-specific counters (refreshed/removed/unavailable/not_found) into
185
+ # log_run.py. Step 4's Claude-driven prompt invokes the Python script with
186
+ # --summary "$LINKEDIN_SUMMARY_FILE", so the file is populated only if Step 4
187
+ # ran end-to-end. Empty file means LinkedIn contributed 0 to every counter.
188
+ LINKEDIN_SUMMARY_FILE=$(mktemp -t fazm-linkedin-summary.XXXXXX)
189
+ # Chain lock cleanup. A plain `trap '...' EXIT` would REPLACE lock.sh's
190
+ # `trap _sa_release_locks EXIT INT TERM HUP`, orphaning the platform-browser
191
+ # lock across runs. Cover all four signals so watchdog SIGTERM also frees it.
192
+ trap 'rm -f "$REPLY_SUMMARY_FILE" "$LINKEDIN_SUMMARY_FILE"; _sa_release_locks' EXIT INT TERM HUP
193
+
194
+ if [ "$RUN_STEP2" -eq 1 ]; then
195
+ # Narrow the Python call per platform. Without --platform we run the
196
+ # default all-platforms pass (kept for manual invocations only).
197
+ STEP2_ARGS=()
198
+ [ "$QUIET" = "--quiet" ] && STEP2_ARGS+=("--quiet")
199
+ STEP2_ARGS+=("--reply-summary" "$REPLY_SUMMARY_FILE")
200
+ case "$PLATFORM" in
201
+ reddit) STEP2_ARGS+=("--reddit-only") ;;
202
+ moltbook) STEP2_ARGS+=("--moltbook-only") ;;
203
+ twitter) STEP2_ARGS+=("--twitter-only") ;;
204
+ esac
205
+
206
+ log "Step 2: API stats (Python) ${STEP2_ARGS[*]:-}"
207
+ python3 "$REPO_DIR/scripts/stats.py" "${STEP2_ARGS[@]}" >> "$LOGFILE" 2>&1
208
+ STEP2_EXIT=$?
209
+ if [ "$STEP2_EXIT" -ne 0 ]; then
210
+ log "Step 2: FAILED (exit $STEP2_EXIT), continuing to next step"
211
+ else
212
+ log "Step 2: Done"
213
+ fi
214
+ else
215
+ log "Step 2: SKIPPED (platform=$PLATFORM)"
216
+ fi
217
+
218
+ # ═══════════════════════════════════════════════════════
219
+ # STEP 3: X/Twitter stats (API via fxtwitter, no browser needed)
220
+ # ═══════════════════════════════════════════════════════
221
+ if [ "$RUN_STEP3" -eq 1 ]; then
222
+ log "Step 3: X/Twitter stats (API via fxtwitter)"
223
+ STEP3_ARGS=("--twitter-only" "--reply-summary" "$REPLY_SUMMARY_FILE")
224
+ [ "$QUIET" = "--quiet" ] && STEP3_ARGS+=("--quiet")
225
+ python3 "$REPO_DIR/scripts/stats.py" "${STEP3_ARGS[@]}" >> "$LOGFILE" 2>&1
226
+ STEP3_EXIT=$?
227
+ if [ "$STEP3_EXIT" -ne 0 ]; then
228
+ log "Step 3: FAILED (exit $STEP3_EXIT)"
229
+ else
230
+ log "Step 3: Done"
231
+ fi
232
+ else
233
+ log "Step 3: SKIPPED (platform=$PLATFORM)"
234
+ fi
235
+
236
+ # ═══════════════════════════════════════════════════════
237
+ # STEP 4: LinkedIn stats (Python CDP-attach to linkedin-agent MCP)
238
+ #
239
+ # Cutover 2026-05-04: replaced the Claude-driven `run_claude.sh stats-step4`
240
+ # heredoc-prompt path with a direct Python script that CDP-attaches to the
241
+ # already-running linkedin-agent MCP, scrapes per-comment reactions, and
242
+ # applies the same DB write-path (scrape_linkedin_stats.update_linkedin_stats).
243
+ # Same data, $0 cost instead of $1-3 per run, 3-5 min instead of 5-10 min.
244
+ # get_run_cost.py --scripts stats-step4 will return $0 going forward; that
245
+ # is correct, not a missed run.
246
+ #
247
+ # Prereqs: linkedin-agent MCP must be alive (Chrome with --remote-debugging-port
248
+ # already running). The post pipeline fires every 15min and primes the browser,
249
+ # so in steady state DevToolsActivePort is always live. If MCP is cold the
250
+ # script returns mcp_not_running / exit 1; stats.sh logs the leg as failed.
251
+ #
252
+ # Lock policy: acquire the bash linkedin-browser lock for 1800s so we
253
+ # serialize against run-linkedin.sh / engage-linkedin.sh /
254
+ # dm-outreach-linkedin.sh / engage-dm-replies.sh (all of which acquire the
255
+ # same lock for 3600s). The earlier Claude-driven Step 4 did NOT acquire
256
+ # this lock, which let it race the post pipeline; the cutover closes that
257
+ # gap. The lock's own ppid==1 orphan-Chrome sweep handles dead Chromes;
258
+ # ensure_browser_healthy is intentionally NOT called here (see inline note
259
+ # at the call site for the --remote-debugging-port=0 incompatibility).
260
+ # ═══════════════════════════════════════════════════════
261
+ if [ "$RUN_STEP4" -eq 1 ]; then
262
+ log "Step 4: LinkedIn stats (Python CDP-attach to linkedin-agent)"
263
+
264
+ # PATH hardening: launchd / nohup / cron environments don't inherit the
265
+ # interactive shell PATH, so `gtimeout` and `python3` may not resolve.
266
+ # Pin to absolute Homebrew + system paths. /usr/bin/python3 is the only
267
+ # python on this Mac with playwright installed in user-site (see CLAUDE.md
268
+ # "Programmatic Gmail Access" + engage-dm-replies.sh:1314 for the same
269
+ # convention). 2026-05-05 cutover bug: bare `python3` in nohup shells
270
+ # resolved to /opt/homebrew/bin/python3 which has psycopg2 but NOT
271
+ # playwright, causing ModuleNotFoundError on every Step 4 fire.
272
+ GTIMEOUT_BIN="/opt/homebrew/bin/gtimeout"
273
+ PY_BIN="/usr/bin/python3"
274
+
275
+ # HTTP-only lane (2026-06-01): the LinkedIn refresh-eligibility count goes
276
+ # through the s4l.ai API via scripts/stats_helper.py. No DATABASE_URL, no
277
+ # psql, no fallback. Prints the same integer the old psql COUNT(*) did.
278
+ LINKEDIN_POSTS=$(python3 "$REPO_DIR/scripts/stats_helper.py" linkedin-refresh-count 2>/dev/null || echo "0")
279
+
280
+ if [ "$LINKEDIN_POSTS" -gt 0 ]; then
281
+ acquire_lock "linkedin-browser" 1800
282
+ # Deliberately do NOT call ensure_browser_healthy here. That helper
283
+ # reads --remote-debugging-port from the Chrome cmdline, but the
284
+ # linkedin-agent MCP launches Chrome with `--remote-debugging-port=0`
285
+ # (let Chrome pick a random port; actual port written to
286
+ # DevToolsActivePort). Result: ensure_browser_healthy reads `0`, probes
287
+ # http://localhost:0, fails, then KILLS the perfectly healthy Chrome —
288
+ # which is the opposite of what we want. The bash lock's orphan-Chrome
289
+ # sweep (ppid==1 filter) already handles the truly-dead case, and our
290
+ # Python script CDP-attaches via DevToolsActivePort so it discovers the
291
+ # real port without needing the cmdline value. If MCP is genuinely cold,
292
+ # the script falls back to launch_persistent_context on the SAME
293
+ # ~/.claude/browser-profiles/linkedin profile the MCP uses (verified
294
+ # against linkedin_browser.PROFILE_DIR), so cookies + fingerprint match
295
+ # the post pipeline regardless of which lifecycle mode is active.
296
+
297
+ # 2026-05-11: scrape_linkedin_stats_browser.py was deprecated 2026-05-05
298
+ # (the per-permalink scrape loop pattern triggered LinkedIn's anti-bot on
299
+ # 2026-05-05). It still exits 2. We now call skill/stats-linkedin.sh, the
300
+ # unified orchestrator: one CDP-attached scrape of /in/me/recent-activity/
301
+ # comments/, two DB writers (replies + posts tables sharing one feed).
302
+ # The orchestrator manages its OWN linkedin-browser lock acquire/release
303
+ # internally, so we release ours first to avoid a self-deadlock.
304
+ release_lock "linkedin-browser"
305
+ "$GTIMEOUT_BIN" 1800 bash "$REPO_DIR/skill/stats-linkedin.sh" \
306
+ >> "$LOGFILE" 2>&1
307
+ STEP4_EXIT=$?
308
+ # Bridge the unified-orchestrator's summary into the legacy
309
+ # $LINKEDIN_SUMMARY_FILE shape that the dashboard parser downstream still
310
+ # expects. The orchestrator writes its posts-table summary internally
311
+ # then deletes it; for the dashboard, surface the combined refresh count
312
+ # via a fresh sidecar populated from the orchestrator's per-fire log.
313
+ LAST_STATS_LOG=$(ls -t "$REPO_DIR/skill/logs/stats-linkedin-"*.log 2>/dev/null | head -1)
314
+ if [ -n "$LAST_STATS_LOG" ] && [ -f "$LAST_STATS_LOG" ]; then
315
+ REFRESHED_TOTAL=$(grep -oE 'total=[0-9]+' "$LAST_STATS_LOG" | tail -1 | sed 's/total=//' || echo 0)
316
+ NOT_FOUND_TOTAL=$(grep -oE 'unmatched=[0-9]+' "$LAST_STATS_LOG" | tail -1 | sed 's/unmatched=//' || echo 0)
317
+ printf '{"refreshed":%s,"removed":0,"unavailable":0,"not_found":%s}\n' \
318
+ "${REFRESHED_TOTAL:-0}" "${NOT_FOUND_TOTAL:-0}" \
319
+ > "$LINKEDIN_SUMMARY_FILE" 2>/dev/null || true
320
+ fi
321
+
322
+ if [ "$STEP4_EXIT" -eq 124 ]; then
323
+ log "Step 4: TIMEOUT (30 min limit reached)"
324
+ elif [ "$STEP4_EXIT" -ne 0 ]; then
325
+ log "Step 4: FAILED (exit $STEP4_EXIT)"
326
+ else
327
+ log "Step 4: Done"
328
+ fi
329
+ else
330
+ log "Step 4: SKIPPED, no LinkedIn posts need stats update ($LINKEDIN_POSTS found)"
331
+ fi
332
+ else
333
+ log "Step 4: SKIPPED (platform=$PLATFORM)"
334
+ fi
335
+
336
+ log "=== Stats Pipeline complete: $(date) ==="
337
+
338
+ # Log run to persistent monitor (matches audit.sh pattern so run_monitor.log
339
+ # covers every launchd job). SCRIPT_TAG uses underscores so the dashboard
340
+ # regex in bin/server.js (^stats_(\w+)$) classifies the row correctly.
341
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
342
+ STATS_FAILED=$(( (STEP1_EXIT != 0 ? 1 : 0) + (STEP2_EXIT != 0 ? 1 : 0) + (STEP3_EXIT != 0 ? 1 : 0) + (STEP4_EXIT != 0 ? 1 : 0) ))
343
+ SCRIPT_TAG="stats${PLATFORM:+_$PLATFORM}"
344
+
345
+ # Parse the per-run log to extract REAL counters for the dashboard. Before
346
+ # 2026-04-28 we logged `--posted "$ACTIVE"` (total active posts in the DB),
347
+ # which was meaningless and made every stats row read like "posted=18216".
348
+ # Now we extract the real per-run counters from the structured summary lines
349
+ # each step prints:
350
+ #
351
+ # Step 1 (Reddit views leg):
352
+ # Reddit Views: <N> had views, <M> DB posts updated, <U> unmatched
353
+ # Step 2 (Reddit detail leg):
354
+ # Reddit: <T> total, <S> skipped, <C> checked, <U> updated, <D> deleted, <R> removed, <E> errors [...]
355
+ # Step 3 (Twitter):
356
+ # Twitter: <T> total, <S> skipped, <C> checked, <U> updated, <D> deleted, <E> errors
357
+ # Step 2 --moltbook-only:
358
+ # Moltbook: <C> checked, <U> updated, <D> deleted, <E> errors
359
+ # Step 4 (LinkedIn): no stdout summary; counters are read from the JSON
360
+ # sidecar file written by scrape_linkedin_stats.py --summary.
361
+ #
362
+ # Missing platforms simply contribute 0 to each total. awk handles parsing
363
+ # robustly even when commas/brackets vary.
364
+ extract_field() {
365
+ # Usage: extract_field <line> <field>
366
+ # Pulls the integer that precedes <field> in a comma-separated counter
367
+ # line such as "Reddit: 4346 total, 1696 skipped, ..." Echoes 0 when the
368
+ # field isn't present.
369
+ #
370
+ # Strips the leading "Platform:" prefix before splitting on commas so the
371
+ # first segment ("Moltbook: 50 checked") doesn't break the leading-integer
372
+ # match. Without this, fields living in the first comma-segment always
373
+ # return 0 (the leading prefix is not numeric).
374
+ local line="$1" field="$2"
375
+ echo "$line" | awk -v f=" $field" '{
376
+ sub(/^[A-Za-z][A-Za-z ]*:[[:space:]]*/, "", $0)
377
+ n = split($0, parts, ",")
378
+ for (i = 1; i <= n; i++) {
379
+ if (index(parts[i], f) > 0) {
380
+ # Strip leading whitespace, then the leading integer is the value.
381
+ gsub(/^[[:space:]]+/, "", parts[i])
382
+ if (match(parts[i], /^[0-9]+/)) {
383
+ print substr(parts[i], RSTART, RLENGTH)
384
+ exit
385
+ }
386
+ }
387
+ }
388
+ print 0
389
+ }'
390
+ }
391
+
392
+ REDDIT_VIEWS_LINE=$(grep -E "^Reddit Views:" "$LOGFILE" 2>/dev/null | tail -1)
393
+ REDDIT_DETAIL_LINE=$(grep -E "^Reddit: [0-9]+ total" "$LOGFILE" 2>/dev/null | tail -1)
394
+ TWITTER_LINE=$(grep -E "^Twitter: [0-9]+ total" "$LOGFILE" 2>/dev/null | tail -1)
395
+ # Moltbook prints `Moltbook: N checked, N updated, N deleted, N errors` (no
396
+ # "total" prefix), so it gets its own grep. LinkedIn doesn't print a
397
+ # structured stdout line; its counters come from $LINKEDIN_SUMMARY_FILE.
398
+ MOLTBOOK_LINE=$(grep -E "^Moltbook: [0-9]+ checked" "$LOGFILE" 2>/dev/null | tail -1)
399
+
400
+ # Reddit views leg: "<M> DB posts updated" — only the "updated" leg matters here.
401
+ REDDIT_VIEWS_UPDATED=0
402
+ if [ -n "$REDDIT_VIEWS_LINE" ]; then
403
+ REDDIT_VIEWS_UPDATED=$(echo "$REDDIT_VIEWS_LINE" | awk '{
404
+ for (i = 1; i <= NF; i++) {
405
+ if ($i == "DB" && $(i+1) == "posts" && $(i+2) == "updated,") {
406
+ print $(i-1); exit
407
+ }
408
+ }
409
+ print 0
410
+ }')
411
+ fi
412
+
413
+ # 2026-05-18 relabel pass. stats.py's structured stdout lines now
414
+ # emit five cleanly-separated fields per platform: total / skipped /
415
+ # checked / changed / errors. Map them to the new dashboard pills:
416
+ # REDDIT_SCANNED -> 'scanned' pill (total considered)
417
+ # REDDIT_SKIPPED -> 'skipped' pill (stable + fresh combined)
418
+ # REDDIT_CHECKED -> 'checked' pill (rows actually hit the API)
419
+ # REDDIT_CHANGED -> 'changed' pill (metric-moved subset)
420
+ # REDDIT_VIEWS_UPDATED -> 'views' pill (Step 1 scrape leg, separate)
421
+ # `updated` is the legacy field name; if stats.py is mid-deploy and
422
+ # the new `changed` field is missing on the line, fall back to `updated`.
423
+ REDDIT_SCANNED=$(extract_field "$REDDIT_DETAIL_LINE" "total")
424
+ REDDIT_CHECKED=$(extract_field "$REDDIT_DETAIL_LINE" "checked")
425
+ REDDIT_CHANGED=$(extract_field "$REDDIT_DETAIL_LINE" "changed")
426
+ if [ "$REDDIT_CHANGED" = "0" ]; then
427
+ # Back-compat: pre-relabel lines used `updated` for the same value.
428
+ REDDIT_CHANGED=$(extract_field "$REDDIT_DETAIL_LINE" "updated")
429
+ fi
430
+ REDDIT_DETAIL_UPDATED="$REDDIT_CHANGED" # legacy alias
431
+ REDDIT_DELETED=$(extract_field "$REDDIT_DETAIL_LINE" "deleted")
432
+ REDDIT_REMOVED_FIELD=$(extract_field "$REDDIT_DETAIL_LINE" "removed")
433
+ REDDIT_SKIPPED=$(extract_field "$REDDIT_DETAIL_LINE" "skipped")
434
+ REDDIT_ERRORS=$(extract_field "$REDDIT_DETAIL_LINE" "errors")
435
+
436
+ TWITTER_SCANNED=$(extract_field "$TWITTER_LINE" "total")
437
+ TWITTER_CHECKED=$(extract_field "$TWITTER_LINE" "checked")
438
+ TWITTER_CHANGED=$(extract_field "$TWITTER_LINE" "changed")
439
+ if [ "$TWITTER_CHANGED" = "0" ]; then
440
+ TWITTER_CHANGED=$(extract_field "$TWITTER_LINE" "updated")
441
+ fi
442
+ TWITTER_UPDATED="$TWITTER_CHANGED" # legacy alias
443
+ TWITTER_DELETED=$(extract_field "$TWITTER_LINE" "deleted")
444
+ TWITTER_SKIPPED=$(extract_field "$TWITTER_LINE" "skipped")
445
+ TWITTER_ERRORS=$(extract_field "$TWITTER_LINE" "errors")
446
+
447
+ MOLTBOOK_CHECKED=$(extract_field "$MOLTBOOK_LINE" "checked")
448
+ MOLTBOOK_UPDATED=$(extract_field "$MOLTBOOK_LINE" "updated")
449
+ MOLTBOOK_DELETED=$(extract_field "$MOLTBOOK_LINE" "deleted")
450
+ MOLTBOOK_ERRORS=$(extract_field "$MOLTBOOK_LINE" "errors")
451
+
452
+ # LinkedIn counters live in a JSON sidecar (no structured stdout line). The
453
+ # file is written by scrape_linkedin_stats.py --summary; absent or empty
454
+ # means the LinkedIn leg didn't run or wrote nothing, so all counters are 0.
455
+ LINKEDIN_REFRESHED=0
456
+ LINKEDIN_REMOVED=0
457
+ LINKEDIN_UNAVAILABLE=0
458
+ LINKEDIN_NOT_FOUND=0
459
+ if [ -s "$LINKEDIN_SUMMARY_FILE" ]; then
460
+ LINKEDIN_REFRESHED=$(python3 -c "import json,sys; d=json.load(open('$LINKEDIN_SUMMARY_FILE')); print(int(d.get('refreshed', 0) or 0))" 2>/dev/null || echo 0)
461
+ LINKEDIN_REMOVED=$(python3 -c "import json,sys; d=json.load(open('$LINKEDIN_SUMMARY_FILE')); print(int(d.get('removed', 0) or 0))" 2>/dev/null || echo 0)
462
+ LINKEDIN_UNAVAILABLE=$(python3 -c "import json,sys; d=json.load(open('$LINKEDIN_SUMMARY_FILE')); print(int(d.get('unavailable', 0) or 0))" 2>/dev/null || echo 0)
463
+ LINKEDIN_NOT_FOUND=$(python3 -c "import json,sys; d=json.load(open('$LINKEDIN_SUMMARY_FILE')); print(int(d.get('not_found', 0) or 0))" 2>/dev/null || echo 0)
464
+ fi
465
+
466
+ CHECKED=$(( REDDIT_CHECKED + TWITTER_CHECKED + MOLTBOOK_CHECKED + LINKEDIN_REFRESHED ))
467
+ # 2026-05-18 relabel: the legacy `UPDATED` summed Reddit's Step 1 view-scrape
468
+ # leg into the same pill as Step 2's "metric actually changed" leg, which
469
+ # silently inflated the number. Keep `UPDATED` wired for back-compat (the
470
+ # log line still emits `updated=N`), but it is now the same value as
471
+ # `CHANGED` so old dashboards behave sanely. New dashboards read the
472
+ # explicit `changed=` and `views_refreshed=` fields instead.
473
+ CHANGED=$(( REDDIT_CHANGED + TWITTER_CHANGED + MOLTBOOK_UPDATED + LINKEDIN_REFRESHED ))
474
+ VIEWS_REFRESHED=$REDDIT_VIEWS_UPDATED
475
+ UPDATED=$CHANGED
476
+ # `SCANNED` is the total rows the run considered, across all platforms.
477
+ # Moltbook has no skip class so its "scanned" == "checked"; LinkedIn ditto.
478
+ SCANNED=$(( REDDIT_SCANNED + TWITTER_SCANNED + MOLTBOOK_CHECKED + LINKEDIN_REFRESHED ))
479
+ REMOVED=$(( REDDIT_DELETED + REDDIT_REMOVED_FIELD + TWITTER_DELETED + MOLTBOOK_DELETED + LINKEDIN_REMOVED ))
480
+ SKIPPED_REAL=$(( REDDIT_SKIPPED + TWITTER_SKIPPED ))
481
+ UNAVAILABLE=$LINKEDIN_UNAVAILABLE
482
+ NOT_FOUND=$LINKEDIN_NOT_FOUND
483
+ # API errors are surfaced via a per-platform counter but are folded into the
484
+ # "failed" pill alongside step-exit counts. Stays bounded since API errors
485
+ # cap at a few hundred and step exits are 0-4.
486
+ FAILED_REAL=$(( STATS_FAILED + REDDIT_ERRORS + TWITTER_ERRORS + MOLTBOOK_ERRORS ))
487
+
488
+ # Pull the reply-refresh count for this platform out of the sidecar JSON.
489
+ # Defaults to 0 if the file is missing or the platform's pass didn't run.
490
+ REPLIES_REFRESHED=0
491
+ if [ -s "$REPLY_SUMMARY_FILE" ]; then
492
+ KEY="${PLATFORM:-reddit}" # all-platforms run reports reddit + twitter + github separately;
493
+ # without --platform we just total them.
494
+ if [ -n "$PLATFORM" ]; then
495
+ REPLIES_REFRESHED=$(python3 -c "import json,sys; d=json.load(open('$REPLY_SUMMARY_FILE')); print(d.get('$KEY', 0))" 2>/dev/null || echo 0)
496
+ else
497
+ REPLIES_REFRESHED=$(python3 -c "import json,sys; d=json.load(open('$REPLY_SUMMARY_FILE')); print(sum(d.values()))" 2>/dev/null || echo 0)
498
+ fi
499
+ fi
500
+
501
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "stats-step4" 2>/dev/null || echo "0.0000")
502
+ python3 "$REPO_DIR/scripts/log_run.py" \
503
+ --script "$SCRIPT_TAG" \
504
+ --posted 0 \
505
+ --skipped "$SKIPPED_REAL" \
506
+ --failed "$FAILED_REAL" \
507
+ --replies-refreshed "$REPLIES_REFRESHED" \
508
+ --checked "$CHECKED" \
509
+ --updated "$UPDATED" \
510
+ --removed "$REMOVED" \
511
+ --unavailable "$UNAVAILABLE" \
512
+ --not-found "$NOT_FOUND" \
513
+ --scanned "$SCANNED" \
514
+ --changed "$CHANGED" \
515
+ --views-refreshed "$VIEWS_REFRESHED" \
516
+ --cost "$_COST" \
517
+ --elapsed "$RUN_ELAPSED"
518
+
519
+ # Clean up old logs (keep last 7 days). Covers both new `stats-<platform>-*`
520
+ # and any legacy `stats-YYYY-*` filenames.
521
+ find "$LOG_DIR" -name "stats-*.log" -mtime +7 -delete 2>/dev/null || true
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # strike-alert.sh — sweep posts for unalerted strikes (status flipped to
3
+ # 'deleted' or 'removed') and email i@m13v.com one notification per strike.
4
+ # Idempotent via posts.strike_email_sent_at. Wired by
5
+ # launchd/com.m13v.social-strike-alert.plist (hourly).
6
+
7
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8
+ LOG_DIR="$REPO_DIR/skill/logs"
9
+ mkdir -p "$LOG_DIR"
10
+ LOG_FILE="$LOG_DIR/strike-alert-$(date +%Y%m%d).log"
11
+
12
+ cd "$REPO_DIR" || exit 1
13
+
14
+ {
15
+ echo "=== $(date -u +%Y-%m-%dT%H:%M:%SZ) strike-alert sweep ==="
16
+ /usr/bin/env python3 scripts/strike_alert.py --sweep
17
+ echo
18
+ } >> "$LOG_FILE" 2>&1
@@ -0,0 +1,87 @@
1
+ #!/bin/bash
2
+ # Shared engagement styles helper.
3
+ # Usage:
4
+ # source styles.sh
5
+ # ASSIGN_FILE=$(mktemp -t saps_style_assign_XXXXXX.json)
6
+ # ASSIGNMENT=$(saps_pick_style twitter posting "$ASSIGN_FILE")
7
+ # PICKED_STYLE=$(echo "$ASSIGNMENT" | python3 -c "import json,sys; print((json.load(sys.stdin).get('style') or ''))")
8
+ # STYLES_BLOCK=$(saps_render_style_block "$ASSIGN_FILE" twitter posting)
9
+ # Requires REPO_DIR to be set before sourcing.
10
+ #
11
+ # Architecture (2026-05-19 picker rollout):
12
+ # - saps_pick_style: programmatic style picker. Emits the assignment JSON to
13
+ # stdout AND writes it to the optional outfile path so a sibling shell var
14
+ # can keep the path around and re-read it later. Replaces the legacy
15
+ # "show all styles, let the model pick" pattern.
16
+ # - saps_render_style_block: turns an assignment JSON file into the compact
17
+ # prompt block (one assigned style + description + example + note, or the
18
+ # invent block with top-N references) plus content rules + anti-patterns
19
+ # + grounding rule.
20
+ # - generate_styles_block: legacy wrapper (pick + render in one go). Retained
21
+ # for shell callers that don't need the picked style downstream (rare; most
22
+ # now want it to filter top_performers and to log drift).
23
+
24
+ # Pick a style and emit the assignment as JSON to stdout. Optionally also
25
+ # writes the JSON to $3 (an outfile path).
26
+ saps_pick_style() {
27
+ local platform="$1"
28
+ local context="${2:-posting}"
29
+ local outfile="${3:-}"
30
+ python3 -c "
31
+ import json, sys
32
+ sys.path.insert(0, '$REPO_DIR/scripts')
33
+ from engagement_styles import pick_style_for_post
34
+ assignment = pick_style_for_post('$platform', context='$context')
35
+ out = '$outfile'
36
+ if out:
37
+ with open(out, 'w') as f:
38
+ json.dump(assignment, f)
39
+ print(json.dumps(assignment))
40
+ " 2>/dev/null || echo '{"mode":"use","style":null,"description":null,"example":null,"note":null,"reference_styles":[],"distribution_snapshot":[]}'
41
+ }
42
+
43
+ # Render the compact prompt block from an assignment JSON file.
44
+ # Includes the styles block + content rules + anti-patterns + grounding rule
45
+ # (the grounding rule is bundled inside get_assigned_style_prompt) +
46
+ # voice relationship rule (introduced 2026-05-27 so the model knows whether
47
+ # to speak AS the matched project's maker or as an outside observer; per
48
+ # project the rule reads voice_relationship in config.json).
49
+ saps_render_style_block() {
50
+ local assign_file="$1"
51
+ local platform="$2"
52
+ local context="${3:-posting}"
53
+ python3 -c "
54
+ import json, sys
55
+ sys.path.insert(0, '$REPO_DIR/scripts')
56
+ from engagement_styles import (
57
+ get_assigned_style_prompt, get_content_rules, get_anti_patterns,
58
+ get_voice_relationship_rule,
59
+ )
60
+ with open('$assign_file', 'r') as f:
61
+ assignment = json.load(f)
62
+ print(get_assigned_style_prompt('$platform', assignment, context='$context'))
63
+ print()
64
+ print(get_voice_relationship_rule())
65
+ print()
66
+ print('## Content rules')
67
+ print(get_content_rules('$platform'))
68
+ print()
69
+ print(get_anti_patterns())
70
+ " 2>/dev/null || echo "(style module unavailable)"
71
+ }
72
+
73
+ # Legacy: pick + render in one call, no assignment exposed to the caller.
74
+ # Equivalent to the pre-2026-05-19 behavior except the prompt now assigns one
75
+ # style instead of listing all of them.
76
+ generate_styles_block() {
77
+ local platform="$1"
78
+ local context="${2:-posting}"
79
+ local tmpfile
80
+ tmpfile=$(mktemp -t saps_style_assign_XXXXXX.json) || {
81
+ echo "(style module unavailable: mktemp failed)"
82
+ return
83
+ }
84
+ saps_pick_style "$platform" "$context" "$tmpfile" >/dev/null
85
+ saps_render_style_block "$tmpfile" "$platform" "$context"
86
+ rm -f "$tmpfile"
87
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # sweep-link-clicks.sh — launchd wrapper for scripts/sweep_post_link_clicks.py.
3
+ #
4
+ # Fires every 30 min from com.m13v.social-sweep-link-clicks.plist.
5
+ #
6
+ # Re-classifies post_link_clicks rows as is_bot=true based on behavioral
7
+ # patterns the per-hit UA regex can't detect:
8
+ #
9
+ # R1 same ip_hash + same code + >=3 hits (repeat-tap on a single link)
10
+ # R2 clicks > views * platform_ctr_ceiling (impossible CTR)
11
+ # R3 ip_hash hits >=5 different codes (crawler sweep)
12
+ # R4 no referrer + dirty-IP companion (suspicious naked GET)
13
+ # R5 >=4 codes within 60s from one ip_hash (burst fan-out)
14
+ #
15
+ # Records the rule in post_link_clicks.bot_reason and rebuilds
16
+ # post_links.clicks (humans only) for affected codes.
17
+ #
18
+ # Single-flight: takes the project lock so a slow run can't stack with
19
+ # the next launchd fire.
20
+
21
+ set -uo pipefail
22
+
23
+ REPO_DIR="$HOME/social-autoposter"
24
+
25
+ # shellcheck source=/dev/null
26
+ [ -f "$REPO_DIR/.env" ] && source "$REPO_DIR/.env"
27
+
28
+ cd "$REPO_DIR" || exit 2
29
+
30
+ # shellcheck source=lock.sh
31
+ source "$REPO_DIR/skill/lock.sh"
32
+ acquire_lock sweep-link-clicks 5
33
+
34
+ RUN_START=$(date +%s)
35
+ /opt/homebrew/bin/python3.11 "$REPO_DIR/scripts/sweep_post_link_clicks.py" --cron
36
+ EXIT_CODE=$?
37
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
38
+
39
+ echo "[$(date +%H:%M:%S)] === done in ${RUN_ELAPSED}s (exit=${EXIT_CODE}) ==="
40
+ exit "$EXIT_CODE"