@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,353 @@
1
+ #!/usr/bin/env python3
2
+ """scripts/author_history_block.py — cross-platform prior-interaction context.
3
+
4
+ Given a target author + platform, prints a soft-context block summarizing
5
+ our recent comments on that author's threads (last N days, capped at K
6
+ most-recent). Empty output when no history. Designed to be injected into
7
+ the per-candidate section of draft prompts so the model can vary angle and
8
+ not repeat itself.
9
+
10
+ Wired into (one callsite each):
11
+ - skill/run-twitter-cycle.sh (Phase 2b-prep CANDIDATE_BLOCK loop)
12
+ - scripts/engage_reddit.py (reply draft prompt builder)
13
+ - scripts/post_reddit.py (build_draft_prompt)
14
+ - scripts/post_github.py (build_prompt)
15
+ - skill/run-linkedin.sh (Phase B prompt template)
16
+
17
+ CLI:
18
+ python3 scripts/author_history_block.py --platform twitter --author tom_doerr
19
+ python3 scripts/author_history_block.py --platform reddit --author lazycodewiz \\
20
+ --days 60 --limit 8
21
+
22
+ Stdout is prose, ready to paste into a prompt. Empty stdout when no rows.
23
+ Stderr only on argparse errors and DB failures; never raises mid-cycle.
24
+
25
+ LinkedIn caveat: thread_author_handle is the display name (not a unique
26
+ vanity slug), so two distinct LinkedIn users with the same display name
27
+ will collide. We document this in the block header rather than guard,
28
+ because the cost of a collision is "show one more harmless prior comment"
29
+ not anything dangerous.
30
+ """
31
+
32
+ import argparse
33
+ import os
34
+ import sys
35
+ from datetime import datetime
36
+
37
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
38
+ sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
39
+ from http_api import api_get # noqa: E402
40
+
41
+
42
+ PLATFORM_ALIAS = {
43
+ "x": "twitter",
44
+ "twitter": "twitter",
45
+ "reddit": "reddit",
46
+ "linkedin": "linkedin",
47
+ "github": "github",
48
+ "github_issues": "github",
49
+ "moltbook": "moltbook",
50
+ }
51
+
52
+
53
+ # Per-process cache for active campaign suffixes; populated lazily on first
54
+ # format_block() call. None = not loaded yet; [] = loaded but empty.
55
+ _ACTIVE_CAMPAIGN_SUFFIXES_CACHE = None
56
+
57
+
58
+ def _load_active_campaign_suffixes():
59
+ """Best-effort: return a list of currently-active campaign suffix literals.
60
+
61
+ Mirrors the helper of the same name in scripts/top_performers.py. We
62
+ duplicate (rather than import) to keep this module's failure mode
63
+ independent of top_performers' larger dependency surface.
64
+
65
+ Used to strip the suffix from `our_content` before injecting prior
66
+ interactions into the draft prompt, so the LLM never learns to echo
67
+ the suffix in its drafts (which would then double-fire when the
68
+ tool-layer injection at twitter_browser.reply_to_tweet / reddit_browser
69
+ appends a second copy). See feedback_suffix_injection_gating.md for the
70
+ history; this closes the 4th leak path that the 2026-05-19 sweep missed.
71
+
72
+ On any failure returns []: missing strip is preferable to crashing the
73
+ prompt assembly path.
74
+ """
75
+ global _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
76
+ if _ACTIVE_CAMPAIGN_SUFFIXES_CACHE is not None:
77
+ return _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
78
+ suffixes = []
79
+ try:
80
+ from http_api import api_get # noqa: E402
81
+ resp = api_get(
82
+ "/api/v1/campaigns",
83
+ query={"status": "active", "has_suffix": "true", "limit": 500},
84
+ )
85
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
86
+ for r in rows:
87
+ s = (r.get("suffix") or "").strip()
88
+ if s and s not in suffixes:
89
+ suffixes.append(s)
90
+ except Exception as e:
91
+ print(
92
+ f"[author_history_block] _load_active_campaign_suffixes failed: {e!r}",
93
+ file=sys.stderr,
94
+ )
95
+ _ACTIVE_CAMPAIGN_SUFFIXES_CACHE = suffixes
96
+ return suffixes
97
+
98
+
99
+ def _strip_active_campaign_suffixes(text, suffixes):
100
+ """Trailing-only, idempotent strip of any active-campaign suffix.
101
+
102
+ Identical contract to top_performers._strip_active_campaign_suffixes.
103
+ Idempotent loop also collapses an already-doubled historical suffix
104
+ (e.g. "... written with s4lai written with s4lai") to clean text.
105
+ Trailing-only so we never touch the body of the comment.
106
+ """
107
+ if not text or not suffixes:
108
+ return text
109
+ cleaned = text.rstrip()
110
+ changed = True
111
+ while changed:
112
+ changed = False
113
+ for sfx in suffixes:
114
+ if sfx and cleaned.endswith(sfx):
115
+ cleaned = cleaned[: -len(sfx)].rstrip()
116
+ changed = True
117
+ return cleaned
118
+
119
+
120
+ def _normalize(handle):
121
+ """Lowercase + strip @, u/, / prefixes. Empty/'unknown' → empty string."""
122
+ if not handle:
123
+ return ""
124
+ h = str(handle).strip().lower()
125
+ while h and h[0] in "@/":
126
+ h = h[1:]
127
+ if h.startswith("u/"):
128
+ h = h[2:]
129
+ h = h.strip()
130
+ if h in ("", "unknown", "[deleted]", "deleted"):
131
+ return ""
132
+ return h
133
+
134
+
135
+ # Column order returned by GET /api/v1/posts/author-history (which is the
136
+ # single source of truth for the SQL; the route comment notes "keep the column
137
+ # list + filters in sync"). format_block below indexes the tuple positionally,
138
+ # so this order is load-bearing.
139
+ def _parse_dt(s):
140
+ """Parse the API's ISO posted_at into a datetime; None on any failure.
141
+
142
+ format_block / fetch's summary call `.date()` on this, so a real datetime
143
+ is required where present. Falls back to the leading YYYY-MM-DD so older
144
+ Pythons (pre-3.11 fromisoformat can't take 'Z' or variable fractional
145
+ digits) still yield a usable date instead of crashing the prompt path.
146
+ """
147
+ if not s:
148
+ return None
149
+ try:
150
+ return datetime.fromisoformat(str(s).strip().replace("Z", "+00:00"))
151
+ except Exception:
152
+ try:
153
+ return datetime.fromisoformat(str(s)[:10])
154
+ except Exception:
155
+ return None
156
+
157
+
158
+ def _row_to_tuple(r):
159
+ return (
160
+ r.get("id"),
161
+ _parse_dt(r.get("posted_at")),
162
+ r.get("project_name"),
163
+ r.get("our_content"),
164
+ r.get("thread_title"),
165
+ r.get("upvotes") or 0,
166
+ r.get("replies_count") or 0,
167
+ r.get("views") or 0,
168
+ r.get("their_first_reply"),
169
+ )
170
+
171
+
172
+ def fetch(platform, author, days=30, limit=5):
173
+ """Return list of tuples matching SQL columns. Empty on bad input/no rows.
174
+
175
+ Always emits one stderr line per call so pipeline logs show injection
176
+ activity. Status token (INJECTED / EMPTY / SKIPPED / ERROR) is the
177
+ leading word after the tag for fast grep.
178
+
179
+ Grep recipes (see latest log via `ls -t skill/logs/<platform>*.log | head -1`):
180
+ grep '\\[author_history_block\\] INJECTED' <log> # confirmed wins
181
+ grep '\\[author_history_block\\] EMPTY' <log> # author has no prior history
182
+ grep '\\[author_history_block\\] SKIPPED' <log> # blank/unknown author field
183
+ grep '\\[author_history_block\\] ERROR' <log> # DB or query failure
184
+ """
185
+ plat = PLATFORM_ALIAS.get(str(platform).lower(), str(platform).lower())
186
+ norm = _normalize(author)
187
+ if not norm:
188
+ print(
189
+ f"[author_history_block] SKIPPED platform={plat} "
190
+ f"author_input={author!r} reason=empty_or_unknown_handle",
191
+ file=sys.stderr,
192
+ )
193
+ return []
194
+ try:
195
+ resp = api_get(
196
+ "/api/v1/posts/author-history",
197
+ query={
198
+ "platform": plat,
199
+ "author": norm,
200
+ "days": int(days),
201
+ "limit": int(limit),
202
+ },
203
+ )
204
+ json_rows = ((resp or {}).get("data") or {}).get("rows") or []
205
+ rows = [_row_to_tuple(r) for r in json_rows]
206
+ if not rows:
207
+ print(
208
+ f"[author_history_block] EMPTY platform={plat} "
209
+ f"author={norm} days={days} limit={limit}",
210
+ file=sys.stderr,
211
+ )
212
+ return rows
213
+ # Compute compact summary: latest + oldest date + project, total likes
214
+ # received on prior comments, count of prior threads that got a reply.
215
+ # These give a one-line "what got injected" preview without dumping
216
+ # the full block to the log.
217
+ latest = rows[0]
218
+ oldest = rows[-1]
219
+ latest_date = latest[1].date().isoformat() if latest[1] else "?"
220
+ oldest_date = oldest[1].date().isoformat() if oldest[1] else "?"
221
+ latest_proj = latest[2] or "?"
222
+ total_likes = sum((r[5] or 0) for r in rows)
223
+ n_with_their_reply = sum(1 for r in rows if r[8])
224
+ print(
225
+ f"[author_history_block] INJECTED platform={plat} "
226
+ f"author={norm} rows={len(rows)} days={days} "
227
+ f"latest={latest_date}({latest_proj}) oldest={oldest_date} "
228
+ f"likes_total={total_likes} they_replied={n_with_their_reply}",
229
+ file=sys.stderr,
230
+ )
231
+ return rows
232
+ except Exception as e:
233
+ print(
234
+ f"[author_history_block] ERROR platform={plat} author={norm} "
235
+ f"error={e!r}",
236
+ file=sys.stderr,
237
+ )
238
+ return []
239
+
240
+
241
+ def _truncate(s, n):
242
+ s = (s or "").replace("\n", " ").replace("\r", " ").strip()
243
+ if len(s) <= n:
244
+ return s
245
+ return s[: n - 3].rstrip() + "..."
246
+
247
+
248
+ def format_block(rows, author, platform, days):
249
+ """Render the per-candidate prompt block. Returns '' when rows is empty."""
250
+ if not rows:
251
+ return ""
252
+ plat = PLATFORM_ALIAS.get(str(platform).lower(), str(platform).lower())
253
+ norm = _normalize(author)
254
+ # Display: keep author's natural form on platforms where the handle IS the
255
+ # name (linkedin/moltbook). Use the canonical @/u/ prefix on platforms
256
+ # where users recognize it that way.
257
+ if plat == "twitter":
258
+ handle_disp = "@" + norm
259
+ elif plat == "reddit":
260
+ handle_disp = "u/" + norm
261
+ elif plat == "linkedin":
262
+ handle_disp = str(author).strip()
263
+ else:
264
+ handle_disp = norm
265
+
266
+ header = (
267
+ f"PRIOR INTERACTIONS WITH {handle_disp} "
268
+ f"(our last {len(rows)} comments to this author, "
269
+ f"window={days}d, latest first):"
270
+ )
271
+ lines = [header]
272
+ # Load active campaign suffixes ONCE per format_block call so we strip
273
+ # them off `our_content` BEFORE truncation. Short Twitter replies
274
+ # (≤140 chars total) would otherwise show the suffix verbatim in the
275
+ # exemplar, training the LLM to echo it; the tool layer then appends
276
+ # a second copy. See feedback_suffix_injection_gating.md.
277
+ suffix_strip_list = _load_active_campaign_suffixes()
278
+ for row in rows:
279
+ (
280
+ _id,
281
+ posted_at,
282
+ project,
283
+ our_content,
284
+ _thread_title,
285
+ upvotes,
286
+ replies_count,
287
+ views,
288
+ their_first_reply,
289
+ ) = row
290
+ date = posted_at.date().isoformat() if posted_at else "?"
291
+ proj = project or "?"
292
+ our_content_clean = _strip_active_campaign_suffixes(
293
+ our_content, suffix_strip_list
294
+ )
295
+ ours = _truncate(our_content_clean, 140)
296
+ eng_bits = []
297
+ if upvotes:
298
+ eng_bits.append(f"likes={upvotes}")
299
+ if replies_count:
300
+ eng_bits.append(f"replies={replies_count}")
301
+ if views:
302
+ eng_bits.append(f"views={views}")
303
+ eng_str = (" [" + ", ".join(eng_bits) + "]") if eng_bits else " [no engagement]"
304
+ lines.append(f"- {date} ({proj}): \"{ours}\"{eng_str}")
305
+ if their_first_reply:
306
+ tr = _truncate(their_first_reply, 110)
307
+ lines.append(f" -> they replied: \"{tr}\"")
308
+ lines.append(
309
+ "Use as SOFT CONTEXT only: vary angle, avoid repeating phrasing or "
310
+ "anecdotes. Do NOT over-reference (never write 'as I said before'). "
311
+ "If our prior take got pushback, soften; if it landed well, keep the voice."
312
+ )
313
+ if plat == "linkedin":
314
+ lines.append(
315
+ "(LinkedIn caveat: matched on display name. If the candidate's "
316
+ "account looks unrelated to the prior posts, ignore this block.)"
317
+ )
318
+ return "\n".join(lines)
319
+
320
+
321
+ def render(platform, author, days=30, limit=5):
322
+ """Convenience for Python callers: returns the block string (possibly empty)."""
323
+ rows = fetch(platform, author, days=days, limit=limit)
324
+ return format_block(rows, author, platform, days)
325
+
326
+
327
+ def main():
328
+ p = argparse.ArgumentParser(
329
+ description="Print prior-interaction context for a target author."
330
+ )
331
+ p.add_argument(
332
+ "--platform",
333
+ required=True,
334
+ help="twitter | reddit | linkedin | github | moltbook (aliases: x, github_issues)",
335
+ )
336
+ p.add_argument(
337
+ "--author",
338
+ required=True,
339
+ help="Target author's handle (any case, leading @ or u/ tolerated)",
340
+ )
341
+ p.add_argument("--days", type=int, default=30, help="Look-back window (default 30)")
342
+ p.add_argument(
343
+ "--limit", type=int, default=5, help="Max interactions to include (default 5)"
344
+ )
345
+ args = p.parse_args()
346
+
347
+ block = render(args.platform, args.author, days=args.days, limit=args.limit)
348
+ if block:
349
+ print(block)
350
+
351
+
352
+ if __name__ == "__main__":
353
+ main()
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ """Box-side autopilot stall watchdog (fleet backstop).
3
+
4
+ Fires a Sentry event when the draft autopilot's scheduled-task routines stop
5
+ draining the local job queue. The most common cause is the user logging Claude
6
+ Desktop into a DIFFERENT account, which leaves the two queue-worker routines
7
+ (saps-phase1-query / saps-phase2b-draft) registered only under the OLD account's
8
+ session, so nothing claims the jobs the pipeline enqueues. The routines' SKILL.md
9
+ files live in a GLOBAL dir and survive the switch, so the old "is the SKILL.md on
10
+ disk?" check stayed falsely green while drafting silently died for hours.
11
+
12
+ The menu bar already surfaces this to the user (title -> "S4L ⚠" + a "Re-arm
13
+ autopilot" item). This watcher is the part the user can't see: a fleet-side alert
14
+ so a sustained stall pages us even when nobody is looking at the menu bar.
15
+
16
+ Design mirrors the stall signal in mcp/menubar/s4l_menubar.py (_autopilot_stalled)
17
+ and mcp/src/index.ts (autopilotStalled) — keep the threshold in sync:
18
+ stalled = the autopilot is configured (both worker SKILL.md files present)
19
+ AND a draft job has sat unclaimed in pending/ past STALL_SECONDS.
20
+ False-positive free: an idle queue (no candidates) has no pending job at all, so
21
+ a quiet pipeline never trips this.
22
+
23
+ Idempotency: only ONE Sentry event per stall episode, and only after the stall
24
+ has persisted ALERT_AFTER consecutive checks (so a single slow claim during a
25
+ restart doesn't page). State lives in <queue>/stall-watch.json; reset when the
26
+ stall clears.
27
+
28
+ Runs as launchd com.m13v.social-autopilot-stall-watch (StartInterval 120) off the
29
+ owned venv (needs sentry-sdk + scripts/ on sys.path via S4L_REPO_DIR). Stdlib
30
+ otherwise. Best-effort: never raises into launchd.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import glob
36
+ import json
37
+ import os
38
+ import sys
39
+ import time
40
+
41
+ # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists and
42
+ # scheduled-task prompts still export SAPS_*; this process reads S4L_*.
43
+ import s4l_env # noqa: E402 (lives next to this file in scripts/)
44
+
45
+ s4l_env.mirror()
46
+
47
+ # Keep in sync with AUTOPILOT_STALL_SECONDS (menubar) / AUTOPILOT_STALL_MS (index.ts).
48
+ STALL_SECONDS = 180
49
+ # A job CLAIMED but never finished (sits in running/ this long) means a worker
50
+ # picked it up and then died mid-run — the claude -p drafting child never came up
51
+ # or crashed. Must be generous enough to clear the longest real drafting turn so a
52
+ # healthy run never trips it. Keep in sync with AUTOPILOT_RUNNING_STALL_SECONDS
53
+ # (menubar). See _oldest_running_age.
54
+ RUNNING_STALL_SECONDS = 900
55
+ # Require the stall to persist this many consecutive checks before paging, so a
56
+ # transient slow claim (e.g. right after a Claude restart) doesn't false-alarm.
57
+ # At StartInterval 120 that is ~6 min of continuous stall.
58
+ ALERT_AFTER = 3
59
+
60
+ WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
61
+
62
+
63
+ def _state_dir() -> str:
64
+ return os.environ.get("S4L_STATE_DIR") or os.path.join(
65
+ os.path.expanduser("~"), ".social-autoposter-mcp"
66
+ )
67
+
68
+
69
+ def _queue_root() -> str:
70
+ return os.path.join(_state_dir(), "claude-queue")
71
+
72
+
73
+ def _watch_state_path() -> str:
74
+ return os.path.join(_queue_root(), "stall-watch.json")
75
+
76
+
77
+ def _claude_config_dir() -> str:
78
+ return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(
79
+ os.path.expanduser("~"), ".claude"
80
+ )
81
+
82
+
83
+ def _autopilot_configured() -> bool:
84
+ """Both worker routines have their SKILL.md on disk = the autopilot was set up
85
+ here (so 'no drafts draining' is a real stall, not just unfinished setup)."""
86
+ base = os.path.join(_claude_config_dir(), "scheduled-tasks")
87
+ return all(
88
+ os.path.exists(os.path.join(base, tid, "SKILL.md")) for tid in WORKER_TASK_IDS
89
+ )
90
+
91
+
92
+ def _consecutive_timeouts() -> int:
93
+ """The producer's LATCHED stall count: consecutive enqueue->timeout cycles with
94
+ no drain since. Persists across the between-cycle gap, so it's the durable
95
+ signal (the pending file is gone between cycles). Cleared on any successful
96
+ drain. See claude_job.py::drain_status_path."""
97
+ try:
98
+ with open(os.path.join(_queue_root(), "drain-status.json")) as f:
99
+ return int((json.load(f) or {}).get("consecutive_timeouts", 0) or 0)
100
+ except Exception:
101
+ return 0
102
+
103
+
104
+ def _recent_rate_limit(window: int = 1200) -> bool:
105
+ """True if a worker run in the last `window` seconds hit the Claude weekly/usage
106
+ limit. That stall is EXPECTED and auto-resets, so it must NOT page Sentry —
107
+ paging would be pure noise. Reads the ~/.s4l-worker transcript bucket."""
108
+ try:
109
+ now = time.time()
110
+ files = glob.glob(
111
+ os.path.expanduser("~/.claude/projects/*s4l-worker*/*.jsonl")
112
+ )
113
+ recent = sorted(
114
+ (f for f in files if (now - os.path.getmtime(f)) <= window),
115
+ key=os.path.getmtime,
116
+ reverse=True,
117
+ )[:5]
118
+ for f in recent:
119
+ try:
120
+ low = open(f).read().lower()
121
+ except Exception:
122
+ continue
123
+ if "weekly limit" in low or "usage limit" in low or "hit your limit" in low:
124
+ return True
125
+ except Exception:
126
+ pass
127
+ return False
128
+
129
+
130
+ def _oldest_pending_age() -> float | None:
131
+ """Seconds since the oldest unclaimed pending draft job was written, or None
132
+ if nothing is pending (idle queue). The FAST signal: catches a fresh stall
133
+ before the first full producer timeout has latched."""
134
+ pend_root = os.path.join(_queue_root(), "pending")
135
+ oldest = None
136
+ for sub in glob.glob(os.path.join(pend_root, "*")):
137
+ for jf in glob.glob(os.path.join(sub, "*.json")):
138
+ if jf.endswith(".tmp"):
139
+ continue
140
+ try:
141
+ m = os.path.getmtime(jf)
142
+ except OSError:
143
+ continue
144
+ if oldest is None or m < oldest:
145
+ oldest = m
146
+ if oldest is None:
147
+ return None
148
+ return time.time() - oldest
149
+
150
+
151
+ def _oldest_running_age() -> float | None:
152
+ """Seconds since the oldest CLAIMED-but-unfinished job was written, or None if
153
+ nothing is in flight. A worker claims by moving a job pending/ -> running/ and
154
+ only removes it on result, so a job lingering in running/ far past any real
155
+ drafting turn means the worker claimed it and then wedged mid-run (dead/never-
156
+ spawned claude -p child). This is the ONLY signal for that case: pending-age is
157
+ silent (the job left pending/) and the producer's drain latch hasn't fired yet
158
+ (it's still inside its own timeout). running/ is flat (see claude_job.py)."""
159
+ run_root = os.path.join(_queue_root(), "running")
160
+ oldest = None
161
+ for jf in glob.glob(os.path.join(run_root, "*.json")):
162
+ if jf.endswith(".tmp"):
163
+ continue
164
+ try:
165
+ m = os.path.getmtime(jf)
166
+ except OSError:
167
+ continue
168
+ if oldest is None or m < oldest:
169
+ oldest = m
170
+ if oldest is None:
171
+ return None
172
+ return time.time() - oldest
173
+
174
+
175
+ def _read_state() -> dict:
176
+ try:
177
+ with open(_watch_state_path()) as f:
178
+ return json.load(f)
179
+ except Exception:
180
+ return {}
181
+
182
+
183
+ def _write_state(obj: dict) -> None:
184
+ try:
185
+ os.makedirs(_queue_root(), exist_ok=True)
186
+ tmp = f"{_watch_state_path()}.tmp.{os.getpid()}"
187
+ with open(tmp, "w") as f:
188
+ json.dump(obj, f)
189
+ os.replace(tmp, _watch_state_path())
190
+ except Exception:
191
+ pass
192
+
193
+
194
+ def _sentry():
195
+ """Import the pipeline's Sentry helper (S4L_REPO_DIR/scripts on path)."""
196
+ repo = os.environ.get("S4L_REPO_DIR")
197
+ if repo:
198
+ scripts = os.path.join(repo, "scripts")
199
+ if scripts not in sys.path:
200
+ sys.path.insert(0, scripts)
201
+ import sentry_init # noqa: E402
202
+
203
+ return sentry_init
204
+
205
+
206
+ def main() -> int:
207
+ age = _oldest_pending_age()
208
+ run_age = _oldest_running_age()
209
+ timeouts = _consecutive_timeouts()
210
+ # Three complementary signals, OR'd; all gated on the autopilot actually being
211
+ # configured here. (1) durable producer drain latch, (2) fast pending-age (job
212
+ # never claimed), (3) running-age (job claimed then wedged mid-run). (3) is the
213
+ # only one that catches a worker dying after it picked up the job.
214
+ stalled = _autopilot_configured() and (
215
+ timeouts >= 1
216
+ or (age is not None and age > STALL_SECONDS)
217
+ or (run_age is not None and run_age > RUNNING_STALL_SECONDS)
218
+ )
219
+ # A rate-limit stall is expected and self-heals at the quota reset — never page
220
+ # for it (and re-arm can't fix it). Treat it as "not an actionable stall" so the
221
+ # episode resets and a LATER real stall (orphaned routines) still alerts.
222
+ if stalled and _recent_rate_limit():
223
+ stalled = False
224
+
225
+ st = _read_state()
226
+ consecutive = int(st.get("consecutive", 0))
227
+ alerted = bool(st.get("alerted", False))
228
+
229
+ if not stalled:
230
+ # Recovered (or never stalled) -> reset the episode so the next stall pages.
231
+ if consecutive or alerted:
232
+ _write_state({"consecutive": 0, "alerted": False})
233
+ return 0
234
+
235
+ consecutive += 1
236
+ age_str = f"{int(age)}s" if age is not None else "n/a (between cycles)"
237
+ run_age_str = f"{int(run_age)}s" if run_age is not None else "n/a (none in flight)"
238
+ # Distinguish the two shapes so the alert points at the right cause: a claimed-
239
+ # but-wedged job (running-age) is a mid-run worker death, not an orphaned routine.
240
+ wedged_inflight = run_age is not None and run_age > RUNNING_STALL_SECONDS
241
+ if consecutive >= ALERT_AFTER and not alerted:
242
+ try:
243
+ sentry = _sentry()
244
+ sentry.init()
245
+ cause = (
246
+ "a worker claimed a draft job and then died mid-run (claude -p child "
247
+ "never came up / crashed)"
248
+ if wedged_inflight
249
+ else "scheduled-task routines likely orphaned — Claude Desktop account change?"
250
+ )
251
+ sentry.capture_message(
252
+ "social-autoposter autopilot stalled: draft jobs are not being "
253
+ f"drained ({cause}). producer consecutive timeouts={timeouts}, "
254
+ f"oldest pending job age={age_str}, oldest in-flight (running) job "
255
+ f"age={run_age_str}, sustained {consecutive} checks.",
256
+ level="error",
257
+ tags={
258
+ "component": "autopilot",
259
+ "issue": "stall",
260
+ "stall_shape": "inflight_wedged" if wedged_inflight else "not_draining",
261
+ "consecutive_timeouts": str(timeouts),
262
+ "oldest_pending_age_s": str(int(age)) if age is not None else "",
263
+ "oldest_running_age_s": str(int(run_age)) if run_age is not None else "",
264
+ },
265
+ )
266
+ sentry.flush()
267
+ except Exception:
268
+ # No Sentry (helper/SDK missing) -> at least leave a local breadcrumb.
269
+ sys.stderr.write(
270
+ f"[stall-watch] autopilot stalled (timeouts={timeouts}, "
271
+ f"pending_age={age_str}, running_age={run_age_str}) but Sentry report failed\n"
272
+ )
273
+ alerted = True
274
+
275
+ _write_state({"consecutive": consecutive, "alerted": alerted, "at": time.time()})
276
+ return 0
277
+
278
+
279
+ if __name__ == "__main__":
280
+ try:
281
+ sys.exit(main())
282
+ except Exception as e: # never let launchd see a non-zero/crash loop
283
+ sys.stderr.write(f"[stall-watch] unexpected error: {e}\n")
284
+ sys.exit(0)