@m13v/s4l 1.6.197-rc.7

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 +1314 -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 +497 -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,350 @@
1
+ #!/usr/bin/env python3
2
+ """counterparty_history.py — shared cross-pipeline counterparty memory.
3
+
4
+ Both the Reddit (engage_reddit.py) and Twitter (engage_twitter_helper.py)
5
+ public-engagement pipelines call get_counterparty_history_block(...) before
6
+ drafting a reply to a specific user. The block surfaces two lanes:
7
+
8
+ 1. DM cross-thread history: from the dms table, scoped to OTHER posts
9
+ (different post_id). Reuses /api/v1/dms?their_author=X&exclude_post_id=N.
10
+ Also returns a same-post disengage signal (hard-skip in callers) when
11
+ the engage-dm-replies pipeline has already classified this person as
12
+ declined / not_our_prospect / stale on the CURRENT post.
13
+
14
+ 2. Public-reply history: prior public comments WE made replying to this
15
+ author, via /api/v1/replies?their_author=X&status=replied. Lets the model
16
+ see whether it's repeating itself with this person, what tone has worked
17
+ before, what archetype has been used.
18
+
19
+ Returns (same_post_disengage, block_text). block_text is "" when no history
20
+ exists in either lane. Callers concatenate block_text into their prompt
21
+ (self-titled with its own H2 header, so no caller-side wrapping needed).
22
+
23
+ Why one shared helper: before 2026-05-19 the Reddit pipeline had its own
24
+ check_cross_pipeline_history() that pulled the DM lane only, while Twitter
25
+ had no counterparty memory at all. Splitting per-platform forks meant
26
+ either pipeline could drift on what gets surfaced (e.g. Reddit added the
27
+ public-reply lane while Twitter still flew blind). Single helper, both
28
+ callers, symmetric behavior.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+ import sys
35
+ from datetime import datetime, timedelta, timezone
36
+ from concurrent.futures import ThreadPoolExecutor
37
+
38
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
39
+ from http_api import api_get # noqa: E402
40
+
41
+
42
+ def _fmt_date(s):
43
+ """Format an ISO-ish timestamp string as YYYY-MM-DD, tolerant of None."""
44
+ if not s:
45
+ return "unknown"
46
+ try:
47
+ return str(s)[:10]
48
+ except Exception:
49
+ return "unknown"
50
+
51
+
52
+ def _truncate(text, n=140):
53
+ if not text:
54
+ return ""
55
+ t = str(text).replace("\n", " ").strip()
56
+ return t if len(t) <= n else t[: n - 1] + "..."
57
+
58
+
59
+ _TWITTER_STATUS_RE = re.compile(r"/status/(\d+)")
60
+ _REDDIT_COMMENT_RE = re.compile(r"/comments/([a-z0-9]+)/")
61
+
62
+
63
+ def _conversation_root(platform, post_id, their_comment_url):
64
+ """Best-effort key identifying the conversation root for grouping.
65
+
66
+ - Our own post: post_id (always wins when present and non-zero).
67
+ - Twitter guest thread: '/status/<id>' from the URL.
68
+ - Reddit guest thread: '/comments/<id>/' from the URL.
69
+ - Fallback: the URL with the last path segment stripped.
70
+ """
71
+ if post_id:
72
+ return f"post:{post_id}"
73
+ if not their_comment_url:
74
+ return None
75
+ if platform == "x":
76
+ m = _TWITTER_STATUS_RE.search(their_comment_url)
77
+ if m:
78
+ return f"x_status:{m.group(1)}"
79
+ if platform == "reddit":
80
+ m = _REDDIT_COMMENT_RE.search(their_comment_url)
81
+ if m:
82
+ return f"r_thread:{m.group(1)}"
83
+ return f"url:{their_comment_url.rsplit('/', 1)[0]}"
84
+
85
+
86
+ def _fetch_author_summary(platform, author, days=7):
87
+ """Compute bot/loop-judgment stats for `author` in the last `days` window.
88
+
89
+ Returns a one-line summary string (or "" when there is no history).
90
+ Signals: total candidates, our replied count, our skipped count,
91
+ distinct conversation roots, our_replies / distinct_roots ratio (the
92
+ "engagement-loop shape" metric — closer to 1.0 = farm-shaped), skip
93
+ rate (% of our heuristics filtering this person out — low = bait too
94
+ clean), span_hours.
95
+ """
96
+ if not author:
97
+ return ""
98
+ since_ts = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
99
+ try:
100
+ resp = api_get(
101
+ "/api/v1/replies",
102
+ query={
103
+ "platform": platform,
104
+ "their_author": author,
105
+ "since": since_ts,
106
+ "limit": 500,
107
+ "order_by": "discovered_at",
108
+ },
109
+ )
110
+ rows = ((resp or {}).get("data") or {}).get("replies") or []
111
+ except Exception as e:
112
+ print(
113
+ f"[counterparty_history] summary fetch failed for "
114
+ f"{platform}/@{author}: {e}",
115
+ file=sys.stderr,
116
+ )
117
+ return ""
118
+
119
+ total = len(rows)
120
+ if total == 0:
121
+ return ""
122
+
123
+ replied = sum(1 for r in rows if r.get("status") == "replied")
124
+ skipped = sum(1 for r in rows if r.get("status") == "skipped")
125
+ roots = {_conversation_root(platform, r.get("post_id"), r.get("their_comment_url")) for r in rows}
126
+ roots.discard(None)
127
+ distinct_roots = len(roots) or 1
128
+
129
+ skip_pct = (skipped / total * 100.0) if total else 0.0
130
+ ratio = (replied / distinct_roots) if distinct_roots else 0.0
131
+
132
+ timestamps = []
133
+ for r in rows:
134
+ ts = r.get("discovered_at") or r.get("replied_at")
135
+ if not ts:
136
+ continue
137
+ try:
138
+ timestamps.append(datetime.fromisoformat(str(ts).replace("Z", "+00:00")))
139
+ except Exception:
140
+ continue
141
+ span_h = 0.0
142
+ if len(timestamps) >= 2:
143
+ span_h = (max(timestamps) - min(timestamps)).total_seconds() / 3600.0
144
+
145
+ return (
146
+ f"SUMMARY (last {days}d): {total} candidates, {replied} our_replies, "
147
+ f"{skipped} skipped ({skip_pct:.1f}% skip_rate), "
148
+ f"{distinct_roots} distinct conversation_roots "
149
+ f"(replies/root={ratio:.2f}, closer to 1.0 = farm-shaped), "
150
+ f"span={span_h:.1f}h"
151
+ )
152
+
153
+
154
+ def _fetch_dm_history(platform, author, post_id):
155
+ """Returns (same_post_disengage, other_thread_lines).
156
+
157
+ same_post_disengage is a dict {dm_id, interest_level, conversation_status, ...}
158
+ when this person has been classified declined/not_our_prospect/stale on
159
+ THIS post by the engage-dm-replies pipeline. Caller hard-skips.
160
+
161
+ other_thread_lines is a list of bullet strings for the soft-context
162
+ block (different post_id; tier, status, target_project, last message).
163
+ """
164
+ same_post_disengage = None
165
+ other_lines = []
166
+
167
+ if post_id:
168
+ try:
169
+ same_resp = api_get(
170
+ "/api/v1/dms",
171
+ query={
172
+ "platform": platform,
173
+ "their_author": author,
174
+ "post_id": post_id,
175
+ "limit": 25,
176
+ "order_by": "last_message_at",
177
+ },
178
+ )
179
+ same_rows = ((same_resp or {}).get("data") or {}).get("dms") or []
180
+ for d in same_rows:
181
+ interest = d.get("interest_level")
182
+ convo_status = d.get("conversation_status")
183
+ if interest in ("declined", "not_our_prospect") or convo_status == "stale":
184
+ same_post_disengage = {
185
+ "dm_id": d.get("id"),
186
+ "interest_level": interest,
187
+ "conversation_status": convo_status,
188
+ "qualification_status": d.get("qualification_status"),
189
+ "last_message_at": d.get("last_message_at"),
190
+ }
191
+ break
192
+ except Exception as e:
193
+ print(
194
+ f"[counterparty_history] same-post dm check failed for "
195
+ f"{platform}/@{author} post={post_id}: {e}",
196
+ file=sys.stderr,
197
+ )
198
+
199
+ try:
200
+ query = {
201
+ "platform": platform,
202
+ "their_author": author,
203
+ "min_message_count": 1,
204
+ "with_last_message": "true",
205
+ "order_by": "last_message_at",
206
+ "limit": 5,
207
+ }
208
+ if post_id:
209
+ query["exclude_post_id"] = post_id
210
+ other_resp = api_get("/api/v1/dms", query=query)
211
+ other_rows = ((other_resp or {}).get("data") or {}).get("dms") or []
212
+ for r in other_rows:
213
+ ts = _fmt_date(r.get("last_message_at"))
214
+ interest = r.get("interest_level") or "unset"
215
+ mode = r.get("mode") or "unset"
216
+ status = r.get("conversation_status") or "unset"
217
+ tier = r.get("tier") if r.get("tier") is not None else "?"
218
+ msgs = r.get("message_count") or 0
219
+ target = r.get("target_project") or "-"
220
+ last = _truncate(r.get("last_msg"), 140)
221
+ other_lines.append(
222
+ f"- dm #{r.get('id')} on post #{r.get('post_id')} (last activity {ts}): "
223
+ f"interest={interest}, mode={mode}, status={status}, "
224
+ f"tier={tier}, messages={msgs}, target_project={target}\n"
225
+ f" last: {last}"
226
+ )
227
+ except Exception as e:
228
+ print(
229
+ f"[counterparty_history] other-thread dm fetch failed for "
230
+ f"{platform}/@{author}: {e}",
231
+ file=sys.stderr,
232
+ )
233
+
234
+ return same_post_disengage, other_lines
235
+
236
+
237
+ def _fetch_public_reply_history(platform, author, current_reply_id=None, limit=5):
238
+ """Returns a list of bullet strings describing our prior public replies
239
+ to this author (status=replied, our_reply_content non-empty).
240
+
241
+ Pulls limit+2 from the API so the client-side exclude_id filter (drop
242
+ the current reply we are about to draft) still yields `limit` lines.
243
+ """
244
+ try:
245
+ resp = api_get(
246
+ "/api/v1/replies",
247
+ query={
248
+ "platform": platform,
249
+ "their_author": author,
250
+ "status": "replied",
251
+ "has_our_reply_content": "true",
252
+ "order_by": "replied_at",
253
+ "limit": int(limit) + 2,
254
+ },
255
+ )
256
+ rows = ((resp or {}).get("data") or {}).get("replies") or []
257
+ except Exception as e:
258
+ print(
259
+ f"[counterparty_history] public-reply fetch failed for "
260
+ f"{platform}/@{author}: {e}",
261
+ file=sys.stderr,
262
+ )
263
+ return []
264
+
265
+ lines = []
266
+ for r in rows:
267
+ if current_reply_id and r.get("id") == current_reply_id:
268
+ continue
269
+ ts = _fmt_date(r.get("replied_at"))
270
+ style = r.get("engagement_style") or "?"
271
+ upv = r.get("upvotes") if r.get("upvotes") is not None else "?"
272
+ cmts = r.get("comments_count") if r.get("comments_count") is not None else "?"
273
+ post_id = r.get("post_id")
274
+ their_snippet = _truncate(r.get("their_content"), 120)
275
+ our_snippet = _truncate(r.get("our_reply_content"), 200)
276
+ lines.append(
277
+ f"- {ts} on post #{post_id} (style={style}, engagement: {upv} upvotes / {cmts} replies)\n"
278
+ f" they said: {their_snippet}\n"
279
+ f" we said: {our_snippet}"
280
+ )
281
+ if len(lines) >= limit:
282
+ break
283
+ return lines
284
+
285
+
286
+ def get_counterparty_history_block(platform, author, current_post_id=None, current_reply_id=None):
287
+ """Build the shared 'Prior history with @author' block.
288
+
289
+ Returns (same_post_disengage, block_text). block_text is "" when there
290
+ is nothing to surface in either lane.
291
+
292
+ Parallelizes the two API lanes (DM check + public-reply fetch) so the
293
+ helper's wall-clock is ~max(dm_latency, replies_latency) rather than
294
+ the sum.
295
+ """
296
+ if not author:
297
+ return None, ""
298
+
299
+ with ThreadPoolExecutor(max_workers=3) as ex:
300
+ dm_fut = ex.submit(_fetch_dm_history, platform, author, current_post_id)
301
+ pub_fut = ex.submit(_fetch_public_reply_history, platform, author, current_reply_id)
302
+ sum_fut = ex.submit(_fetch_author_summary, platform, author, 7)
303
+ same_post_disengage, dm_lines = dm_fut.result()
304
+ pub_lines = pub_fut.result()
305
+ summary_line = sum_fut.result()
306
+
307
+ if not dm_lines and not pub_lines and not summary_line:
308
+ return same_post_disengage, ""
309
+
310
+ parts = [f"## Prior history with @{author}"]
311
+ parts.append(
312
+ "Soft context from past interactions across DM and public-reply rails. "
313
+ "Use this to gauge tone, avoid repeating yourself, and notice if they "
314
+ "have already declined or warmed up to a topic. Does NOT auto-block; "
315
+ "you still decide reply or skip based on the current thread."
316
+ )
317
+ if summary_line:
318
+ parts.append("")
319
+ parts.append(summary_line)
320
+ if dm_lines:
321
+ parts.append("\n### DM threads on other posts")
322
+ parts.append("\n".join(dm_lines))
323
+ if pub_lines:
324
+ parts.append("\n### Our prior public replies to this person")
325
+ parts.append("\n".join(pub_lines))
326
+
327
+ return same_post_disengage, "\n".join(parts)
328
+
329
+
330
+ if __name__ == "__main__":
331
+ # Manual smoke-test:
332
+ # python3 counterparty_history.py reddit Secret_Theme3192
333
+ # python3 counterparty_history.py x someuser
334
+ import argparse
335
+
336
+ ap = argparse.ArgumentParser()
337
+ ap.add_argument("platform")
338
+ ap.add_argument("author")
339
+ ap.add_argument("--post-id", type=int, default=None)
340
+ ap.add_argument("--reply-id", type=int, default=None)
341
+ args = ap.parse_args()
342
+
343
+ disengage, block = get_counterparty_history_block(
344
+ args.platform, args.author,
345
+ current_post_id=args.post_id,
346
+ current_reply_id=args.reply_id,
347
+ )
348
+ print(f"same_post_disengage: {disengage}")
349
+ print("---")
350
+ print(block or "(empty block — no history found)")
package/scripts/db.py ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Shared env loader for social-autoposter (clean, shipped).
3
+
4
+ The published package talks to the central store exclusively through the S4L
5
+ HTTP API (scripts/http_api.py). It ships NO direct database dependency: no
6
+ psycopg2, no DATABASE_URL requirement.
7
+
8
+ This module provides `load_env()` (the only DB-agnostic helper every pipeline
9
+ needs) and, for LOCAL operator installs only, re-exports the direct-Postgres
10
+ connection layer from `db_direct.py` when that file is present. `db_direct.py`
11
+ is excluded from the npm tarball, so on a clean install the direct-DB symbols
12
+ resolve to a hard-error stub instead of importing psycopg2.
13
+
14
+ .env is read from ~/social-autoposter/.env (pre-filled on install).
15
+ """
16
+
17
+ import os
18
+
19
+ ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
20
+
21
+
22
+ def load_env():
23
+ if os.path.exists(ENV_PATH):
24
+ with open(ENV_PATH) as f:
25
+ for line in f:
26
+ line = line.strip()
27
+ if line and not line.startswith('#') and '=' in line:
28
+ k, v = line.split('=', 1)
29
+ os.environ.setdefault(k.strip(), v.strip())
30
+
31
+
32
+ # Re-export the direct-Postgres layer when running on a local operator install
33
+ # (db_direct.py present). In the published package db_direct.py is absent, so
34
+ # these names resolve to a stub that fails loudly if anything tries to open a
35
+ # direct DB connection — by design, the shipped pipelines use the HTTP API.
36
+ try:
37
+ from db_direct import ( # noqa: F401
38
+ get_conn,
39
+ PGConn,
40
+ snapshot_post_views,
41
+ )
42
+ except ImportError:
43
+ def _no_direct_db(*_args, **_kwargs):
44
+ raise RuntimeError(
45
+ "Direct database access is not available in this build. "
46
+ "The published social-autoposter package uses the S4L HTTP API "
47
+ "(scripts/http_api.py); set AUTOPOSTER_API_BASE in ~/social-autoposter/.env."
48
+ )
49
+
50
+ def get_conn(*_args, **_kwargs): # noqa: F811
51
+ return _no_direct_db()
52
+
53
+ def snapshot_post_views(*_args, **_kwargs): # noqa: F811
54
+ # No-op stub: stats snapshots are an operator-local concern.
55
+ return None
56
+
57
+ PGConn = None
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """Discover Claude Desktop profiles on this Mac, which one is running, and what's installed.
3
+
4
+ A "profile" is any user-data-dir Claude Desktop has ever run with:
5
+ - the default: ~/Library/Application Support/Claude
6
+ - named ones: ~/Library/Application Support/Claude-<label> (account rotator convention)
7
+
8
+ Running detection: parse `ps` for /Applications/Claude.app/Contents/MacOS/Claude
9
+ and read its --user-data-dir flag (absence of the flag = default profile).
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ APP_SUPPORT = Path.home() / "Library" / "Application Support"
21
+ ROTATOR_LABELS = Path.home() / "claude-account-rotator" / "labels.json"
22
+
23
+ # Dirs that start with "Claude" but are not Claude Desktop profiles
24
+ NOT_PROFILES = {"Claude Extensions", "ClaudeMeter", "claude-code"}
25
+
26
+
27
+ def is_profile_dir(p: Path) -> bool:
28
+ if not p.is_dir() or p.name in NOT_PROFILES:
29
+ return False
30
+ # An Electron user-data-dir has Local State and/or Preferences; a used
31
+ # Claude profile additionally has config.json.
32
+ return (p / "Local State").exists() or (p / "config.json").exists()
33
+
34
+
35
+ def rotator_accounts() -> dict:
36
+ try:
37
+ return json.loads(ROTATOR_LABELS.read_text())
38
+ except Exception:
39
+ return {}
40
+
41
+
42
+ def running_instances() -> dict:
43
+ """Return {resolved_profile_path: pid} for every live Claude main process."""
44
+ out = subprocess.run(
45
+ ["ps", "ax", "-o", "pid=,command="], capture_output=True, text=True
46
+ ).stdout
47
+ found = {}
48
+ for line in out.splitlines():
49
+ m = re.match(r"\s*(\d+)\s+(/Applications/Claude\.app/Contents/MacOS/Claude)(\s|$)", line)
50
+ if not m:
51
+ continue
52
+ pid = int(m.group(1))
53
+ # Value may contain spaces ("Application Support"); it runs to the
54
+ # next --flag or end of line.
55
+ dm = re.search(r"--user-data-dir=(.+?)(?=\s+--|$)", line)
56
+ profile = Path(dm.group(1)) if dm else APP_SUPPORT / "Claude"
57
+ found[str(profile)] = pid
58
+ return found
59
+
60
+
61
+ def profile_info(p: Path, running: dict, accounts: dict) -> dict:
62
+ info = {
63
+ "profile": p.name,
64
+ "path": str(p),
65
+ "running_pid": running.get(str(p)),
66
+ }
67
+ # Account label: rotator convention Claude-<label>
68
+ label = p.name.removeprefix("Claude-") if p.name != "Claude" else "default"
69
+ acct = accounts.get(label, {})
70
+ info["account_label"] = label
71
+ info["account_email"] = acct.get("email")
72
+ info["account_status"] = acct.get("status")
73
+
74
+ cfg_path = p / "config.json"
75
+ if cfg_path.exists():
76
+ try:
77
+ cfg = json.loads(cfg_path.read_text())
78
+ # Logged-in org UUIDs leak through the dxt allowlist cache keys
79
+ orgs = sorted(
80
+ {k.split(":")[-1] for k in cfg if k.startswith("dxt:allowlistEnabled:")}
81
+ )
82
+ info["org_uuids"] = orgs
83
+ info["signed_in"] = "oauth:tokenCacheV2" in cfg or "oauth:tokenCache" in cfg
84
+ except Exception as e:
85
+ info["config_error"] = str(e)
86
+
87
+ ext_dir = p / "Claude Extensions"
88
+ info["extensions"] = sorted(d.name for d in ext_dir.iterdir() if d.is_dir()) if ext_dir.is_dir() else []
89
+
90
+ # Last activity: mtime of config.json (touched constantly while running)
91
+ try:
92
+ ts = cfg_path.stat().st_mtime if cfg_path.exists() else p.stat().st_mtime
93
+ info["last_active"] = datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().isoformat(timespec="seconds")
94
+ except OSError:
95
+ pass
96
+ return info
97
+
98
+
99
+ def main():
100
+ profiles = sorted(
101
+ (p for p in APP_SUPPORT.glob("Claude*") if is_profile_dir(p)),
102
+ key=lambda p: p.name,
103
+ )
104
+ running = running_instances()
105
+ accounts = rotator_accounts()
106
+ result = {
107
+ "running_count": len(running),
108
+ "profiles": [profile_info(p, running, accounts) for p in profiles],
109
+ }
110
+ # Flag any running instance whose profile dir we failed to enumerate
111
+ known = {str(p) for p in profiles}
112
+ orphans = {path: pid for path, pid in running.items() if path not in known}
113
+ if orphans:
114
+ result["running_unknown_profiles"] = orphans
115
+ json.dump(result, sys.stdout, indent=2)
116
+ print()
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()