@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
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Slim snapshot of the S4L autopilot scheduled-task registry state.
3
+
4
+ Answers, per install, the question we were previously blind to: "are the
5
+ queue-worker scheduled tasks actually running from the dedicated ~/.s4l-worker
6
+ folder (so their once-a-minute sessions don't flood the user's project history),
7
+ or are they still mislocated, and is the deprecated autopilot task lingering?"
8
+
9
+ The heartbeat (scripts/heartbeat.sh + mcp/src/telemetry.ts) attaches the
10
+ `--summary` output as the top-level `scheduled_tasks` field of the heartbeat
11
+ body, so the state lands on the installations row centrally, keyed by
12
+ install_id, with no SSH needed.
13
+
14
+ Read-only: never edits a registry (that is the menubar's
15
+ `_rewrite_scheduled_task_cwd` job). Stdlib only, /usr/bin/python3 compatible.
16
+
17
+ Kept in sync with mcp/menubar/s4l_menubar.py (WORKER_TASK_IDS,
18
+ DEPRECATED_TASK_IDS, WORKER_CWD, SCHED_REGISTRY_GLOB) and scripts/s4l_box_update.sh.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import glob
25
+ import json
26
+ import os
27
+ import sys
28
+
29
+ # --- Kept in sync with mcp/menubar/s4l_menubar.py ---------------------------
30
+ WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
31
+ DEPRECATED_TASK_IDS = ("social-autoposter-autopilot",)
32
+ WORKER_CWD = os.path.join(os.path.expanduser("~"), ".s4l-worker")
33
+ # "Claude*": the host app can run with a custom --user-data-dir (per-account
34
+ # dirs like "Claude-mediar"), putting the live registry outside plain "Claude/".
35
+ # Keep in sync with scripts/schedule_state.py::SCHED_REGISTRY_GLOB.
36
+ SCHED_REGISTRY_GLOB = os.path.join(
37
+ os.path.expanduser("~"), "Library", "Application Support", "Claude*",
38
+ "claude-code-sessions", "*", "*", "scheduled-tasks.json",
39
+ )
40
+
41
+
42
+ def _cwd_tail(cwd: str) -> str:
43
+ """Last path component only, so we surface WHERE a mislocated task points
44
+ (e.g. 's4lsetup' vs '.s4l-worker') without shipping the full home path /
45
+ username off-box."""
46
+ if not cwd:
47
+ return ""
48
+ return os.path.basename(os.path.normpath(cwd))
49
+
50
+
51
+ def build_summary() -> dict:
52
+ """Scan every scheduled-tasks.json registry and summarize the S4L worker
53
+ tasks' folder state. Never raises; a broken/absent registry yields an empty
54
+ (but well-formed) summary so the heartbeat body is always valid."""
55
+ tasks: list[dict] = []
56
+ registries = 0
57
+ deprecated_present = False
58
+ seen_ids: set[str] = set()
59
+
60
+ try:
61
+ files = glob.glob(SCHED_REGISTRY_GLOB)
62
+ except Exception:
63
+ files = []
64
+
65
+ for f in files:
66
+ try:
67
+ with open(f) as fh:
68
+ d = json.load(fh)
69
+ except Exception:
70
+ continue
71
+ registries += 1
72
+ for t in (d.get("scheduledTasks") or []):
73
+ tid = t.get("id")
74
+ if tid in DEPRECATED_TASK_IDS:
75
+ deprecated_present = True
76
+ continue
77
+ if tid not in WORKER_TASK_IDS:
78
+ continue
79
+ cwd = t.get("cwd") or ""
80
+ in_worker = os.path.normpath(cwd) == os.path.normpath(WORKER_CWD) if cwd else False
81
+ seen_ids.add(tid)
82
+ tasks.append({
83
+ "id": tid,
84
+ "enabled": bool(t.get("enabled")),
85
+ "in_worker_dir": in_worker,
86
+ "cwd_tail": _cwd_tail(cwd),
87
+ "last_run_at": t.get("lastRunAt"),
88
+ })
89
+
90
+ mislocated = sum(1 for t in tasks if not t["in_worker_dir"])
91
+ return {
92
+ "worker_dir_tail": _cwd_tail(WORKER_CWD),
93
+ "registries": registries,
94
+ "worker_tasks": len(tasks),
95
+ "missing_worker_tasks": sorted(set(WORKER_TASK_IDS) - seen_ids),
96
+ "mislocated": mislocated,
97
+ # all_in_worker_dir is False when there are zero worker tasks too, since
98
+ # "no autopilot registered" is itself a state worth seeing centrally.
99
+ "all_in_worker_dir": bool(tasks) and mislocated == 0,
100
+ "deprecated_present": deprecated_present,
101
+ "tasks": tasks,
102
+ }
103
+
104
+
105
+ def main() -> int:
106
+ parser = argparse.ArgumentParser(description=__doc__)
107
+ parser.add_argument(
108
+ "--summary",
109
+ action="store_true",
110
+ help="Print a slim JSON summary to stdout and exit. Used by the heartbeat.",
111
+ )
112
+ args = parser.parse_args()
113
+
114
+ summary = build_summary()
115
+ if args.summary:
116
+ sys.stdout.write(json.dumps(summary, separators=(",", ":")))
117
+ else:
118
+ sys.stdout.write(json.dumps(summary, indent=2) + "\n")
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ score_linkedin_candidates.py
4
+
5
+ Reads a JSON array of LinkedIn SERP candidates (from stdin or --file),
6
+ computes engagement velocity + LinkedIn-tuned virality score, and upserts
7
+ into linkedin_candidates. Also expires + prunes old rows.
8
+
9
+ Why this exists, vs Twitter's score_twitter_candidates.py:
10
+
11
+ Twitter's pipeline runs every 20 min and uses a two-phase delta-momentum
12
+ gate (T0 scan, sleep 5 min, T1 rescan, score = delta engagement / 5 min).
13
+ LinkedIn is ad-hoc and we cannot afford the 5-min wait per cycle, so the
14
+ single-shot substitute is *engagement velocity since post creation*:
15
+
16
+ velocity = (reactions + 2*comments + 3*reposts) / max(age_hours, 0.5)
17
+
18
+ Comments weighted higher than reposts than reactions because comments
19
+ signal a live conversation a reply can join. The 0.5-hour floor stops
20
+ brand-new posts from infinity-spiking.
21
+
22
+ The full virality score layers in author follower reach + age decay so a
23
+ trending post from a sub-50K-follower practitioner outranks a stale
24
+ influencer post with the same raw velocity.
25
+
26
+ Input JSON shape (one element per candidate, scraped via the
27
+ mcp__linkedin-agent walk in run-linkedin.sh Phase B):
28
+
29
+ [
30
+ {
31
+ "post_url": "https://www.linkedin.com/feed/update/urn:li:activity:...",
32
+ "activity_id": "1234567890123456789",
33
+ "all_urns": ["1234567890123456789", "..."],
34
+ "author_name": "First Last",
35
+ "author_profile_url": "https://www.linkedin.com/in/SLUG/",
36
+ "author_followers": 12345,
37
+ "post_text": "first 500 chars",
38
+ "age_hours": 6.5,
39
+ "reactions": 42,
40
+ "comments": 7,
41
+ "reposts": 3,
42
+ "search_topic": "AI agents in production",
43
+ "search_query": "ai agents production",
44
+ "matched_project": "fazm",
45
+ "language": "en",
46
+ "serp_quality_score": 7.5
47
+ }
48
+ ]
49
+
50
+ Usage:
51
+ python3 scripts/score_linkedin_candidates.py --batch-id <id> < candidates.json
52
+ python3 scripts/score_linkedin_candidates.py --file /tmp/c.json --batch-id <id>
53
+ python3 scripts/score_linkedin_candidates.py --expire-only
54
+
55
+ Pair with: top_linkedin_queries.py, top_dud_linkedin_queries.py,
56
+ log_linkedin_search_attempts.py.
57
+ """
58
+
59
+ import argparse
60
+ import json
61
+ import math
62
+ import os
63
+ import re
64
+ import sys
65
+ from datetime import datetime, timezone
66
+
67
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
68
+ from http_api import api_get, api_post
69
+ try: # author exclusion is fail-open: never let it break scoring
70
+ from linkedin_exclusions import load_exclusions, classify_author
71
+ except Exception: # pragma: no cover - helper missing -> exclusion is a no-op
72
+ def load_exclusions(platform="linkedin"):
73
+ return {"hard_slugs": set(), "soft_slugs": set(), "soft_names": set()}
74
+
75
+ def classify_author(author_name, author_profile_url, excl=None):
76
+ return None, ""
77
+ try:
78
+ from account_resolver import resolve as _resolve_account
79
+ except Exception:
80
+ def _resolve_account(_platform): # type: ignore[unused-arg]
81
+ return None
82
+
83
+
84
+ # Engagement weights. Comments worth more than reposts worth more than
85
+ # reactions because comments are the strongest "this thread is alive"
86
+ # signal for an outbound reply.
87
+ W_REACTIONS = 1.0
88
+ W_COMMENTS = 2.0
89
+ W_REPOSTS = 3.0
90
+
91
+ # Floor on age_hours so freshly-posted (<30 min old) posts cannot
92
+ # infinity-spike the velocity score. 0.5 = 30 min.
93
+ AGE_FLOOR_HOURS = 0.5
94
+
95
+ # Maximum age we'll consider. Posts older than this are too cold —
96
+ # the conversation has moved on, our reply lands in a graveyard. Mirrors
97
+ # Twitter's 18h ceiling, scaled up because LinkedIn threads stay live
98
+ # longer (multi-day).
99
+ MAX_AGE_HOURS = 96.0 # 4 days
100
+
101
+ # Freshness gate. We flip stale pending rows to 'expired' so they stop
102
+ # burning judgment tokens, but we NEVER delete rows. Per user instruction
103
+ # 2026-05-08, terminal rows are kept forever for analytics.
104
+ EXPIRE_PENDING_AFTER_HOURS = 96.0 # match MAX_AGE_HOURS
105
+
106
+
107
+ def calculate_velocity_score(cand):
108
+ """Return (velocity, virality, age_hours_clamped).
109
+
110
+ velocity is the raw weighted-engagement-per-hour signal. virality
111
+ layers in follower reach + age decay so the candidate-picker can
112
+ rank across a SERP regardless of absolute size.
113
+ """
114
+ reactions = int(cand.get("reactions", 0) or 0)
115
+ comments = int(cand.get("comments", 0) or 0)
116
+ reposts = int(cand.get("reposts", 0) or 0)
117
+ followers = int(cand.get("author_followers", 0) or 0)
118
+
119
+ age_hours = float(cand.get("age_hours", 0) or 0)
120
+ if age_hours < AGE_FLOOR_HOURS:
121
+ age_hours = AGE_FLOOR_HOURS
122
+
123
+ weighted_eng = (
124
+ W_REACTIONS * reactions
125
+ + W_COMMENTS * comments
126
+ + W_REPOSTS * reposts
127
+ )
128
+ velocity = weighted_eng / age_hours
129
+
130
+ # Author reach multiplier. LinkedIn-specific tuning: practitioner
131
+ # accounts (5K-50K followers) are the sweet spot for outbound
132
+ # replies — they have audience but aren't influencer-saturated, so
133
+ # our reply has a real chance of being seen.
134
+ if followers <= 0:
135
+ # Unknown follower count: don't penalize, just don't reward.
136
+ reach_mult = 0.8
137
+ elif followers < 500:
138
+ reach_mult = 0.4
139
+ elif followers < 2000:
140
+ reach_mult = 0.7
141
+ elif followers < 5000:
142
+ reach_mult = 0.95
143
+ elif followers < 50000:
144
+ reach_mult = 1.0 # sweet spot
145
+ elif followers < 200000:
146
+ reach_mult = 1.2
147
+ elif followers < 500000:
148
+ reach_mult = 1.0
149
+ else:
150
+ reach_mult = 0.85 # mega accounts: lower hit rate, drowned out
151
+
152
+ # Age decay. Half-life 24h on LinkedIn (vs 6h on Twitter): threads
153
+ # stay live longer. ln(2)/24 ≈ 0.0289.
154
+ # 12h = 71%, 24h = 50%, 48h = 25%, 96h = 6%.
155
+ age_decay = math.exp(-0.0289 * age_hours)
156
+
157
+ # Discussion-quality bonus: comments-to-reactions ratio. High ratio
158
+ # (>10%) means it's an actual conversation, not a one-way like dump.
159
+ if reactions > 0:
160
+ disc_ratio = comments / reactions
161
+ else:
162
+ disc_ratio = 0
163
+ disc_bonus = min(disc_ratio * 5, 1.0) # up to +1.0x
164
+
165
+ virality = velocity * reach_mult * age_decay * (1.0 + disc_bonus)
166
+
167
+ return round(velocity, 2), round(virality, 2), round(age_hours, 2)
168
+
169
+
170
+ def _normalize_post_url(url):
171
+ """Normalize a LinkedIn post URL to the canonical /feed/update/<urn> form,
172
+ preserving the URN namespace (activity vs share vs ugcPost).
173
+
174
+ Reality check (verified 2026-05-01 with Andreas Mautsch's "Apple Container"
175
+ post): activity / share / ugcPost URNs for the same logical post are
176
+ DIFFERENT numeric IDs and LinkedIn does NOT auto-redirect across them.
177
+ /feed/update/urn:li:activity:<share_id>/ returns "Post not found" if the
178
+ numeric is actually a share ID. So we MUST keep the original namespace,
179
+ not collapse to activity. See linkedin_url.py docstring for context.
180
+
181
+ Inputs accepted:
182
+ * /feed/update/urn:li:activity:NUMERIC/
183
+ * /feed/update/urn:li:share:NUMERIC/
184
+ * /feed/update/urn:li:ugcPost:NUMERIC/
185
+ * /posts/SLUG-activity-NUMERIC-RANDOM (3-dot-menu copy-link form)
186
+ * /posts/SLUG-share-NUMERIC-RANDOM
187
+ * /posts/SLUG-ugcPost-NUMERIC-RANDOM
188
+ """
189
+ if not url:
190
+ return None
191
+ m = re.search(r"urn:li:(activity|share|ugcPost):(\d{16,19})", url)
192
+ if m:
193
+ return f"https://www.linkedin.com/feed/update/urn:li:{m.group(1)}:{m.group(2)}/"
194
+ # Slug form from "Copy link to post" 3-dot menu. The URN type is
195
+ # encoded in the slug as -activity-NUM-, -share-NUM-, or -ugcPost-NUM-.
196
+ m = re.search(r"-(activity|share|ugcPost)-(\d{16,19})\b", url, re.IGNORECASE)
197
+ if m:
198
+ # Normalize the type token's case (LinkedIn always emits ugcPost
199
+ # camel-cased; activity/share lowercase).
200
+ urn_type = m.group(1)
201
+ if urn_type.lower() == "ugcpost":
202
+ urn_type = "ugcPost"
203
+ else:
204
+ urn_type = urn_type.lower()
205
+ return f"https://www.linkedin.com/feed/update/urn:li:{urn_type}:{m.group(2)}/"
206
+ return url.strip().rstrip("/") + "/"
207
+
208
+
209
+ def _parse_age_hours(cand):
210
+ """Pull age_hours out of the candidate, falling back to post_posted_at.
211
+
212
+ Phase B's scrape generally writes age_hours directly (parsed from the
213
+ relative timestamp string LinkedIn renders, e.g. "5h", "2d"). If the
214
+ LLM instead wrote an ISO timestamp, derive age from it.
215
+ """
216
+ raw = cand.get("age_hours")
217
+ if raw is not None:
218
+ try:
219
+ return float(raw)
220
+ except (TypeError, ValueError):
221
+ pass
222
+ posted_at = cand.get("post_posted_at") or cand.get("posted_at")
223
+ if posted_at:
224
+ try:
225
+ dt = datetime.fromisoformat(str(posted_at).replace("Z", "+00:00"))
226
+ return (datetime.now(timezone.utc) - dt).total_seconds() / 3600.0
227
+ except (ValueError, TypeError):
228
+ pass
229
+ return None
230
+
231
+
232
+ def _fetch_posted_urls():
233
+ """Return the set of normalized LinkedIn thread URLs we've already posted
234
+ on, scoped per-account when an account name is configured (falls back to
235
+ unscoped for legacy single-account behavior).
236
+
237
+ Migrated 2026-06-01 from a direct `SELECT thread_url FROM posts` to the
238
+ s4l.ai HTTP API (GET /api/v1/posts/thread-urls).
239
+ """
240
+ _li_name = _resolve_account("linkedin")
241
+ resp = api_get(
242
+ "/api/v1/posts/thread-urls",
243
+ query={"platform": "linkedin", "our_account": _li_name},
244
+ )
245
+ urls = (resp.get("data") or {}).get("thread_urls") or []
246
+ posted = set()
247
+ for u in urls:
248
+ norm = _normalize_post_url(u)
249
+ if norm:
250
+ posted.add(norm)
251
+ return posted
252
+
253
+
254
+ def upsert_candidates(candidates, batch_id=None):
255
+ """Score and upsert LinkedIn candidates. Returns (inserted, skipped, errors).
256
+
257
+ Migrated 2026-06-01 from direct psycopg2 INSERT...ON CONFLICT to the s4l.ai
258
+ HTTP API (POST /api/v1/linkedin-candidates, which mirrors the upsert
259
+ server-side). The dedup-against-posted query moved to
260
+ GET /api/v1/posts/thread-urls. Scoring stays client-side (pure Python).
261
+ """
262
+ # Dedupe against already-posted LinkedIn threads (the engaged-id check
263
+ # in run-linkedin.sh covers URN-level dedup, but this catches URL-level
264
+ # dupes too in case someone hand-feeds candidates).
265
+ posted_urls = _fetch_posted_urls()
266
+
267
+ inserted = skipped = errors = 0
268
+ _excl = load_exclusions()
269
+
270
+ for cand in candidates:
271
+ if not isinstance(cand, dict):
272
+ continue
273
+ # Author exclusion (config.json + author_blocklist, slug-keyed). The
274
+ # POST-rail discover step already drops these, but guard here too so the
275
+ # linkedin_candidates table never accrues an excluded author regardless
276
+ # of which caller fed us (hand-fed batches, future rails, etc.).
277
+ if classify_author(cand.get("author_name"),
278
+ cand.get("author_profile_url"), _excl)[0] == "hard":
279
+ skipped += 1
280
+ continue
281
+ post_url = _normalize_post_url(cand.get("post_url"))
282
+ if not post_url:
283
+ errors += 1
284
+ continue
285
+
286
+ # Skip URLs we already posted on
287
+ if post_url in posted_urls:
288
+ skipped += 1
289
+ continue
290
+
291
+ age_hours = _parse_age_hours(cand)
292
+ if age_hours is None:
293
+ # Unknown age = treat as cold so it ranks below known-fresh,
294
+ # but don't auto-reject (LinkedIn relative timestamps fail to
295
+ # parse on long-tail formats like "1mo").
296
+ age_hours = MAX_AGE_HOURS
297
+
298
+ if age_hours > MAX_AGE_HOURS:
299
+ skipped += 1
300
+ continue
301
+
302
+ cand["age_hours"] = age_hours
303
+ velocity, virality, age_clamped = calculate_velocity_score(cand)
304
+
305
+ # Resolve post_posted_at if not provided (we can derive from age)
306
+ post_posted_at = cand.get("post_posted_at") or cand.get("posted_at")
307
+ if not post_posted_at and age_hours is not None:
308
+ try:
309
+ from datetime import timedelta
310
+ post_posted_at = (
311
+ datetime.now(timezone.utc) - timedelta(hours=age_hours)
312
+ ).isoformat()
313
+ except Exception:
314
+ post_posted_at = None
315
+
316
+ all_urns = cand.get("all_urns") or []
317
+ if isinstance(all_urns, list):
318
+ all_urns_str = ",".join(str(u) for u in all_urns if u)
319
+ else:
320
+ all_urns_str = str(all_urns)
321
+
322
+ payload = {
323
+ "post_url": post_url,
324
+ "activity_id": cand.get("activity_id") or None,
325
+ "all_urns": all_urns_str or None,
326
+ "author_name": cand.get("author_name") or None,
327
+ "author_profile_url": cand.get("author_profile_url") or None,
328
+ "author_followers": int(cand.get("author_followers") or 0) or None,
329
+ "post_text": (cand.get("post_text") or "") or None,
330
+ "post_posted_at": post_posted_at,
331
+ "age_hours": age_clamped,
332
+ "reactions": int(cand.get("reactions") or 0),
333
+ "comments": int(cand.get("comments") or 0),
334
+ "reposts": int(cand.get("reposts") or 0),
335
+ "engagement_velocity": velocity, # raw
336
+ "velocity_score": virality, # post-multiplier
337
+ "serp_quality_score": (
338
+ float(cand["serp_quality_score"])
339
+ if cand.get("serp_quality_score") is not None else None
340
+ ),
341
+ "search_topic": cand.get("search_topic") or None,
342
+ "search_query": cand.get("search_query") or None,
343
+ "matched_project": cand.get("matched_project") or None,
344
+ "language": cand.get("language") or "en",
345
+ "batch_id": batch_id,
346
+ }
347
+
348
+ try:
349
+ # The endpoint mirrors the ON CONFLICT (post_url) DO UPDATE upsert:
350
+ # COALESCE-bumps discovery fields, overwrites engagement metrics,
351
+ # and preserves terminal statuses ('posted'/'skipped') while
352
+ # resetting everything else to 'pending'.
353
+ api_post("/api/v1/linkedin-candidates", payload)
354
+ inserted += 1
355
+ except SystemExit as e:
356
+ print(f" Error inserting {post_url}: {e}", file=sys.stderr)
357
+ errors += 1
358
+ continue
359
+
360
+ expire_and_prune()
361
+ return inserted, skipped, errors
362
+
363
+
364
+ def expire_and_prune(_conn=None):
365
+ """Flip stale pending rows to 'expired'. We do NOT prune terminal rows
366
+ by age (per user instruction 2026-05-08); every linkedin_candidates row
367
+ is kept forever so we can audit skip reasons, engagement dynamics, and
368
+ project routing across time. Function name kept for caller compatibility.
369
+
370
+ Migrated 2026-06-01 to POST /api/v1/linkedin-candidates/expire-stale.
371
+ The optional _conn arg is ignored (legacy signature compatibility).
372
+ """
373
+ api_post(
374
+ "/api/v1/linkedin-candidates/expire-stale",
375
+ {"hours": int(EXPIRE_PENDING_AFTER_HOURS)},
376
+ )
377
+
378
+
379
+ def main():
380
+ parser = argparse.ArgumentParser()
381
+ parser.add_argument("--file", help="Read JSON from a file instead of stdin")
382
+ parser.add_argument("--batch-id", help="Tag this batch on every row")
383
+ parser.add_argument("--expire-only", action="store_true",
384
+ help="Only run expire/prune, no scoring or insert")
385
+ parser.add_argument("--quiet", action="store_true",
386
+ help="Suppress final stdout summary line")
387
+ args = parser.parse_args()
388
+
389
+ if args.expire_only:
390
+ expire_and_prune()
391
+ if not args.quiet:
392
+ print("Expired/pruned old linkedin_candidates")
393
+ return 0
394
+
395
+ if args.file:
396
+ with open(args.file) as f:
397
+ data = json.load(f)
398
+ else:
399
+ raw = sys.stdin.read().strip()
400
+ if not raw:
401
+ print("score_linkedin_candidates: empty stdin, nothing to score",
402
+ file=sys.stderr)
403
+ return 0
404
+ data = json.loads(raw)
405
+
406
+ if not isinstance(data, list):
407
+ data = [data]
408
+
409
+ inserted, skipped, errors = upsert_candidates(data, batch_id=args.batch_id)
410
+ if not args.quiet:
411
+ print(
412
+ f"score_linkedin_candidates: upserted={inserted} "
413
+ f"skipped={skipped} errors={errors} batch={args.batch_id}"
414
+ )
415
+ return 0
416
+
417
+
418
+ if __name__ == "__main__":
419
+ sys.exit(main())