@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,288 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministically capture + persist + format thread media for the prep step.
3
+
4
+ Companion to the main Twitter posting cycle (run-twitter-cycle.sh Phase 2b-prep,
5
+ 2026-06-03 thread-media feature). The prep prompt forbids the model from calling
6
+ twitter_browser.py, so the SHELL pre-fetches the media of every candidate the
7
+ model is about to draft against, in ONE cheap browser pass, then:
8
+
9
+ 1. persists each candidate's media into twitter_candidates.thread_media (so the
10
+ record survives independent of the model), and
11
+ 2. emits a "MEDIA CONTEXT" prompt block to stdout so the reply-writer can "see"
12
+ the image / video / GIF / link-card it is replying to instead of replying
13
+ text-blind.
14
+
15
+ Input: a TSV file, one `candidate_id<TAB>tweet_url` per line (built by the
16
+ CANDIDATE_BLOCK loop in run-twitter-cycle.sh).
17
+
18
+ Media shape per item: {url, alt, type}, type in image|video|gif|card. An empty
19
+ list [] is valid and meaningful ("captured, none found", distinct from NULL =
20
+ "never captured").
21
+
22
+ Usage:
23
+ python3 scripts/capture_thread_media.py --urls-file /tmp/urls.tsv \\
24
+ [--scroll 1] [--no-persist]
25
+
26
+ Output:
27
+ stdout -> the MEDIA CONTEXT prompt block (empty string if no media at all)
28
+ stderr -> per-candidate diagnostics + a final JSON summary line
29
+ """
30
+
31
+ import argparse
32
+ import json
33
+ import os
34
+ import sys
35
+
36
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
+ from http_api import api_get, api_patch # noqa: E402
38
+
39
+ # Imported lazily inside main() so --help works without a browser / playwright.
40
+
41
+
42
+ def _load_pairs(urls_file):
43
+ """Return [(candidate_id:str, url:str)] from a `cid<TAB>url` TSV file."""
44
+ pairs = []
45
+ with open(urls_file) as f:
46
+ for line in f:
47
+ line = line.rstrip("\n")
48
+ if not line.strip():
49
+ continue
50
+ if "\t" in line:
51
+ cid, url = line.split("\t", 1)
52
+ else:
53
+ # Tolerate a bare-URL line (no cid); skip it, we can't key it.
54
+ continue
55
+ cid = cid.strip()
56
+ url = url.strip()
57
+ if cid and url:
58
+ pairs.append((cid, url))
59
+ return pairs
60
+
61
+
62
+ def _persist(candidate_id, media, repost=None):
63
+ """Persist media (+ repost provenance) onto twitter_candidates via set_media.
64
+
65
+ repost is {"is_repost": bool, "reposted_by": str} or None.
66
+
67
+ POSITIVE-ONLY for repost: we send is_repost/reposted_by ONLY when we detected
68
+ a repost (is_repost True). The candidate URL is the ORIGINAL tweet permalink,
69
+ where X does NOT render the "<X> reposted" timeline banner, so fresh detection
70
+ here is almost always False; sending that False would clobber the authoritative
71
+ flag set at discovery time (the server COALESCEs only on null, not on false).
72
+ Omitting the keys leaves COALESCE(null, is_repost) = the stored value intact.
73
+ """
74
+ payload = {"id": int(candidate_id), "action": "set_media", "thread_media": media}
75
+ if repost is not None and bool(repost.get("is_repost", False)):
76
+ payload["is_repost"] = True
77
+ payload["reposted_by"] = repost.get("reposted_by", "") or ""
78
+ resp = api_patch(
79
+ "/api/v1/twitter-candidates/by-id", payload,
80
+ ok_on_conflict=True, ok_on_404=True,
81
+ )
82
+ if (resp or {}).get("_not_found"):
83
+ return False, "CANDIDATE_NOT_FOUND"
84
+ if not (resp or {}).get("ok"):
85
+ return False, (resp or {}).get("error") or "SET_MEDIA_FAILED"
86
+ return True, None
87
+
88
+
89
+ def _fetch_stored_repost(candidate_id):
90
+ """Read the repost flag stored at discovery time for one candidate.
91
+
92
+ Repost provenance is detected and stored by the DISCOVERY scan (the timeline
93
+ is the only place X renders the "<X> reposted" banner). The prep step runs on
94
+ the original-tweet permalink, which never shows that banner, so we read the
95
+ authoritative stored value here to surface it to the model in the prompt block.
96
+
97
+ Returns {"is_repost": bool, "reposted_by": str}; defaults to a non-repost on
98
+ any error / missing candidate (fail-open: never block the cycle).
99
+ """
100
+ try:
101
+ resp = api_get(
102
+ "/api/v1/twitter-candidates/by-id",
103
+ {"id": int(candidate_id)}, ok_on_404=True,
104
+ )
105
+ except Exception:
106
+ return {"is_repost": False, "reposted_by": ""}
107
+ cand = (resp or {}).get("candidate") or {}
108
+ return {
109
+ "is_repost": bool(cand.get("is_repost", False)),
110
+ "reposted_by": cand.get("reposted_by", "") or "",
111
+ }
112
+
113
+
114
+ def _format_item(item):
115
+ """One ' - <type>: "<alt>" (<url>)' line for the prompt block."""
116
+ t = (item.get("type") or "media").strip()
117
+ alt = (item.get("alt") or "").strip()
118
+ url = (item.get("url") or "").strip()
119
+ alt_part = f'"{alt}"' if alt else "[no description]"
120
+ return f" - {t}: {alt_part} ({url})"
121
+
122
+
123
+ def _build_block(captured):
124
+ """captured: list of (candidate_id, media_list, repost). Returns prompt block.
125
+
126
+ A section is emitted for any candidate that has media OR is a repost, so the
127
+ model is told about repost provenance even when the tweet carries no media.
128
+ """
129
+ sections = []
130
+ for cid, media, repost in captured:
131
+ is_repost = bool((repost or {}).get("is_repost"))
132
+ if not media and not is_repost:
133
+ continue
134
+ body = []
135
+ if is_repost:
136
+ rb = ((repost or {}).get("reposted_by") or "").strip()
137
+ who = f"@{rb}" if rb else "another account"
138
+ body.append(
139
+ f" - REPOST: this is a repost surfaced by {who}. The tweet text "
140
+ "and any media below were written by the ORIGINAL author, not the "
141
+ "reposter. Reply to the original author's content; do not address "
142
+ "the reposter."
143
+ )
144
+ if media:
145
+ body.extend(_format_item(it) for it in media)
146
+ sections.append(f"Candidate {cid}:\n" + "\n".join(body))
147
+ if not sections:
148
+ return ""
149
+ header = (
150
+ "## MEDIA IN THESE THREADS\n"
151
+ "Some candidate threads contain images, videos, GIFs, link-cards, or are "
152
+ "reposts. This is part of the content you are replying to: react to what "
153
+ "the tweet VISUALLY shows, not just its text, and treat reposted content "
154
+ "as the original author's. A candidate NOT listed here had no media and is "
155
+ "not a repost (or capture was skipped); reply to its text as usual. "
156
+ "Descriptions marked [no description] mean the media had no alt-text, so "
157
+ "infer from the thread text and the media type."
158
+ )
159
+ return header + "\n\n" + "\n".join(sections) + "\n"
160
+
161
+
162
+ def main():
163
+ p = argparse.ArgumentParser()
164
+ p.add_argument("--urls-file", required=True,
165
+ help="TSV: one candidate_id<TAB>tweet_url per line.")
166
+ p.add_argument("--scroll", type=int, default=1,
167
+ help="scroll_count passed to the batch scraper (default 1).")
168
+ p.add_argument("--no-persist", action="store_true",
169
+ help="Skip writing thread_media to the DB (format only).")
170
+ args = p.parse_args()
171
+
172
+ pairs = _load_pairs(args.urls_file)
173
+ if not pairs:
174
+ # Nothing to do; emit empty block, exit clean so the shell continues.
175
+ print("", end="")
176
+ print(json.dumps({"captured": 0, "persisted": 0, "with_media": 0, "reposts": 0}), file=sys.stderr)
177
+ return
178
+
179
+ # Lazy import so an empty/short-circuit run never pays the playwright cost.
180
+ from twitter_browser import scrape_many_thread_media
181
+
182
+ urls = [url for _cid, url in pairs]
183
+ try:
184
+ batch = scrape_many_thread_media(urls, scroll_count=args.scroll)
185
+ except Exception as e:
186
+ # Browser failure must NOT break the cycle: emit empty block, log, exit 0.
187
+ print("", end="")
188
+ print(json.dumps({"error": "SCRAPE_FAILED", "detail": str(e)}), file=sys.stderr)
189
+ return
190
+
191
+ # Map url -> {media, repost} (results echo the input url verbatim as thread_url).
192
+ by_url = {}
193
+ for r in (batch or {}).get("results", []):
194
+ by_url[r.get("thread_url")] = {
195
+ "media": r.get("media") or [],
196
+ "repost": {
197
+ "is_repost": bool(r.get("is_repost", False)),
198
+ "reposted_by": r.get("reposted_by", "") or "",
199
+ },
200
+ }
201
+
202
+ captured = [] # (cid, media, repost) for ALL pairs (media may be [])
203
+ persisted = 0
204
+ with_media = 0
205
+ reposts = 0
206
+ access_checked = 0
207
+ access_not_visible = 0
208
+ access_check_limit = int(os.environ.get("S4L_TWITTER_EMPTY_MEDIA_ACCESS_CHECKS", "3"))
209
+ access_wait_ms = int(os.environ.get("S4L_TWITTER_EMPTY_MEDIA_ACCESS_WAIT_MS", "4000"))
210
+ empty_capture_unreliable = None
211
+ for cid, url in pairs:
212
+ rec = by_url.get(url) or {}
213
+ media = rec.get("media", [])
214
+ access = None
215
+ if not media:
216
+ # Empty media is only meaningful when the tweet itself rendered.
217
+ # If x.com served an empty app shell / block / protected page, do
218
+ # NOT persist [] because [] means "captured successfully, no media";
219
+ # leaving NULL lets a later cycle retry instead of poisoning the row.
220
+ if empty_capture_unreliable:
221
+ access = empty_capture_unreliable
222
+ elif access_checked < access_check_limit:
223
+ try:
224
+ from twitter_access_check import diagnose_tweet_access
225
+ access = diagnose_tweet_access(
226
+ url, wait_ms=access_wait_ms, include_public=False,
227
+ )
228
+ access_checked += 1
229
+ except Exception as e:
230
+ access = {"status": "access_check_failed", "reason": str(e)}
231
+ access_checked += 1
232
+ else:
233
+ access = {"status": "unchecked", "reason": "empty_media_access_check_cap"}
234
+ status = access.get("status")
235
+ if status not in ("visible", "visible_no_anchor", "unchecked"):
236
+ access_not_visible += 1
237
+ if status in ("app_not_hydrated", "app_error", "logged_out", "access_check_failed", "unknown"):
238
+ empty_capture_unreliable = access
239
+ print(
240
+ f"[capture_thread_media] cid={cid} url={url} "
241
+ f"access_status={status} reason={access.get('reason')} "
242
+ "leaving thread_media NULL",
243
+ file=sys.stderr,
244
+ )
245
+ captured.append((cid, media, {"is_repost": False, "reposted_by": ""}))
246
+ continue
247
+ fresh = rec.get("repost", {"is_repost": False, "reposted_by": ""})
248
+ # Authoritative repost flag comes from discovery (stored). Fresh permalink
249
+ # detection is a rare bonus; prefer stored, fall back to fresh.
250
+ stored = _fetch_stored_repost(cid)
251
+ if stored.get("is_repost"):
252
+ repost = stored
253
+ elif fresh.get("is_repost"):
254
+ repost = fresh
255
+ else:
256
+ repost = {"is_repost": False, "reposted_by": ""}
257
+ captured.append((cid, media, repost))
258
+ if media:
259
+ with_media += 1
260
+ if repost.get("is_repost"):
261
+ reposts += 1
262
+ if not args.no_persist:
263
+ # _persist is positive-only for repost: pass the FRESH detection (so a
264
+ # newly-seen banner is recorded) but never the stored value back (no
265
+ # round-trip clobber). Media is always persisted.
266
+ ok, err = _persist(cid, media, fresh)
267
+ if ok:
268
+ persisted += 1
269
+ else:
270
+ print(f"[capture_thread_media] persist failed cid={cid}: {err}",
271
+ file=sys.stderr)
272
+
273
+ block = _build_block(captured)
274
+ # stdout = the prompt block ONLY (shell captures it verbatim).
275
+ sys.stdout.write(block)
276
+ print(json.dumps({
277
+ "captured": len(captured),
278
+ "persisted": persisted,
279
+ "with_media": with_media,
280
+ "reposts": reposts,
281
+ "access_checked": access_checked,
282
+ "access_not_visible": access_not_visible,
283
+ "urls_visited": (batch or {}).get("urls_visited", 0),
284
+ }), file=sys.stderr)
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()
@@ -0,0 +1,81 @@
1
+ #!/bin/bash
2
+ # Health check for the browser session-lock fix (2026-06-16).
3
+ # Full context: docs/twitter_browser_lock.md. Logic test: scripts/test_browser_lock.py.
4
+ #
5
+ # Prints [ok ] / [BAD] / [i ] lines so you can tell at a glance whether the fix is
6
+ # present in code AND behaving in production. Exits non-zero if any [BAD] is found.
7
+ #
8
+ # bash scripts/check_browser_lock_health.sh [HOURS] # default lookback 24h
9
+ #
10
+ # What it checks (see docs §4/§5 for the why):
11
+ # 1. fix still present in twitter_browser.py + linkedin_browser.py (catch a revert)
12
+ # 2. defect-b `rm -f` has NOT crept back into skill/*.sh
13
+ # 3. reclaim markers firing = fix actively catching dead holders (positive signal)
14
+ # 4. starvation giveups WITHOUT the peer-alive tell = defect (a) recurring (BAD)
15
+ # 5. shell-lock trap_rm owner=OTHER = a pipeline deleted a LIVE peer's lock (BAD)
16
+ set -u
17
+ cd "$(dirname "$0")/.."
18
+ LOGS="skill/logs"
19
+ HOURS="${1:-24}"
20
+ bad=0
21
+
22
+ # Dated per-run logs that are BOTH within the lookback window AND newer than the lock
23
+ # code file. The `-newer` clause is the key: it counts only runs that started after the
24
+ # fix (or, if the fix is ever reverted, after that revert) -- so day-one pre-fix
25
+ # starvation noise is excluded automatically, and a real regression still surfaces.
26
+ # (NOT launchd-*.log: those are append-only, so their mtime says nothing about when a
27
+ # line inside was written.)
28
+ recent=$(find "$LOGS" -maxdepth 1 -name '*2026-*.log' -mmin "-$((HOURS*60))" \
29
+ -newer scripts/twitter_browser.py 2>/dev/null | grep -v 'launchd-' || true)
30
+ [ -z "$recent" ] && recent="/dev/null" # guard: never let grep read stdin
31
+
32
+ echo "== browser-lock health (last ${HOURS}h of dated per-run logs) =="
33
+
34
+ # 1. fix present in code
35
+ if grep -q _is_python_holder_alive scripts/twitter_browser.py 2>/dev/null \
36
+ && grep -q _is_python_holder_alive scripts/linkedin_browser.py 2>/dev/null; then
37
+ echo "[ok ] fix present (twitter_browser.py + linkedin_browser.py)"
38
+ else
39
+ echo "[BAD] fix MISSING from code -> reverted (re-apply from docs/twitter_browser_lock.md)"; bad=1
40
+ fi
41
+
42
+ # 2. defect-b rm -f gone from shells (anchored so the explanatory comment is not a hit).
43
+ # Scope to *.sh only -- never recurse skill/ (it holds a huge claude-sessions/ tree).
44
+ if grep -hEq '^[[:space:]]*rm -f .*twitter-browser-lock\.json' skill/*.sh skill/lib/*.sh 2>/dev/null; then
45
+ echo "[BAD] defect-b: an actual 'rm -f ...twitter-browser-lock.json' is back in skill/*.sh"; bad=1
46
+ else
47
+ echo "[ok ] no rm -f of the session lock in skill/*.sh"
48
+ fi
49
+
50
+ # 3. positive: reclaim markers (each = a dead holder caught that USED to starve the fleet)
51
+ rec=$(grep -hoE '\[browser_lock\] reclaimed .*reason=[a-z_]+' $recent 2>/dev/null | wc -l | tr -d ' ')
52
+ recd=$(grep -hoE '\[browser_lock\] reclaimed .*reason=dead_python' $recent 2>/dev/null | wc -l | tr -d ' ')
53
+ echo "[i ] reclaim markers fired: ${rec:-0} (of which dead_python: ${recd:-0}) -- 0 is fine if nothing crashed"
54
+
55
+ # 4. starvation: twitter giveup without 'peer alive' / linkedin profile_locked without 'peer_alive'
56
+ bad_tw=$(grep -hE 'locked by session .* giving up' $recent 2>/dev/null | grep -vc 'peer alive' || true)
57
+ bad_li=$(grep -hE 'profile_locked' $recent 2>/dev/null | grep -vc 'peer_alive' || true)
58
+ bad_tw=${bad_tw:-0}; bad_li=${bad_li:-0}
59
+ if [ "$bad_tw" -gt 0 ] || [ "$bad_li" -gt 0 ]; then
60
+ echo "[BAD] old-format starvation giveups: twitter=$bad_tw linkedin=$bad_li (defect a recurring?)"; bad=1
61
+ else
62
+ echo "[ok ] no old-format starvation giveups (twitter + linkedin)"
63
+ fi
64
+
65
+ # 5. shell-lock: dangerous trap_rm owner=OTHER (deleting a live peer's shell lock).
66
+ # NOTE: 'event=stale_reclaim ... owner=OTHER' is LEGITIMATE (reclaiming a dead holder),
67
+ # only 'event=trap_rm ... owner=OTHER' is the bad one.
68
+ if [ -f "$LOGS/lock-events.log" ]; then
69
+ to=$(grep -cE 'event=trap_rm .*owner=OTHER' "$LOGS/lock-events.log" 2>/dev/null); to=${to:-0}
70
+ if [ "$to" -gt 0 ]; then
71
+ echo "[BAD] shell trap_rm owner=OTHER x$to (a pipeline deleted a LIVE peer's shell lock)"; bad=1
72
+ else
73
+ echo "[ok ] shell-lock: no trap_rm owner=OTHER (live-lock deletes)"
74
+ fi
75
+ fi
76
+
77
+ echo "=================================================="
78
+ if [ "$bad" -ne 0 ]; then
79
+ echo "RESULT: ATTENTION NEEDED (see [BAD] above)"; exit 1
80
+ fi
81
+ echo "RESULT: HEALTHY"
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env python3
2
+ """Email-alert when any external_short_links pool runs low.
3
+
4
+ For every project with `external_short_links: true` in config.json, checks the
5
+ (project, platform) pool depth against two thresholds:
6
+
7
+ WARN -- available / total <= 0.20 (i.e., 80% of the pool has been claimed)
8
+ CRITICAL -- available == 0 (pool exhausted, next post returns
9
+ {ok: false, error: 'pool_exhausted'})
10
+
11
+ Emails go to i@m13v.com via the Gmail DWD lane. State lives in the
12
+ `external_pool_alerts` table so we don't spam: same (project, platform,
13
+ severity) is suppressed for 24h after a send.
14
+
15
+ Designed to run on launchd every 30 min. The 20% threshold gives 7-30 days of
16
+ runway warning before a CRITICAL fires (at typical 5-15 posts/day burn). The
17
+ CRITICAL alert is the "on error" case the user asked for; it fires at most
18
+ once per 24h per (project, platform).
19
+
20
+ Usage:
21
+ python3 scripts/check_external_pool_depth.py # check + alert
22
+ python3 scripts/check_external_pool_depth.py --dry-run # report only, no email/state writes
23
+ python3 scripts/check_external_pool_depth.py --force # ignore 24h cooldown
24
+ """
25
+ from __future__ import annotations
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ from datetime import datetime, timezone
31
+ from email.message import EmailMessage
32
+ import base64
33
+
34
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35
+ sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
36
+ sys.path.insert(0, os.path.expanduser('~/gmail-api'))
37
+
38
+ from http_api import api_get, api_post # noqa: E402
39
+
40
+ WARN_REMAINING_RATIO = 0.20
41
+ COOLDOWN_HOURS = 24
42
+ NOTIFICATION_EMAIL = os.environ.get('NOTIFICATION_EMAIL', 'i@m13v.com')
43
+ PLATFORMS = ['reddit', 'twitter', 'linkedin', 'github_issues', 'moltbook']
44
+ CONFIG_PATH = os.path.join(REPO_DIR, 'config.json')
45
+
46
+
47
+ def _scrub_dashes(s: str) -> str:
48
+ return s.replace('—', ',').replace('–', ',') if s else s
49
+
50
+
51
+ def _load_external_projects() -> list[dict]:
52
+ with open(CONFIG_PATH) as f:
53
+ cfg = json.load(f)
54
+ return [p for p in cfg.get('projects', []) if p.get('external_short_links')]
55
+
56
+
57
+ def _pool_depth(project: str, platform: str) -> tuple[int, int]:
58
+ resp = api_get(
59
+ "/api/v1/post-links/pool-depth",
60
+ query={"project_name": project, "platform": platform},
61
+ )
62
+ d = resp.get("data") or {}
63
+ return int(d.get("available") or 0), int(d.get("total") or 0)
64
+
65
+
66
+ def _recent_alert_exists(project: str, platform: str, severity: str) -> bool:
67
+ resp = api_get(
68
+ "/api/v1/external-pool-alerts",
69
+ query={
70
+ "project_name": project,
71
+ "platform": platform,
72
+ "severity": severity,
73
+ "within_hours": COOLDOWN_HOURS,
74
+ },
75
+ )
76
+ return bool((resp.get("data") or {}).get("recent"))
77
+
78
+
79
+ def _record_alert(project: str, platform: str, severity: str,
80
+ available: int, total: int, ratio: float) -> None:
81
+ api_post(
82
+ "/api/v1/external-pool-alerts",
83
+ {
84
+ "project_name": project,
85
+ "platform": platform,
86
+ "severity": severity,
87
+ "available": available,
88
+ "total": total,
89
+ "ratio": ratio,
90
+ },
91
+ )
92
+
93
+
94
+ def _gmail_send(subject: str, body: str) -> None:
95
+ from gmail_dwd_client import gmail_for
96
+ msg = EmailMessage()
97
+ msg['Subject'] = _scrub_dashes(subject)
98
+ msg['From'] = 'social-autoposter <i@m13v.com>'
99
+ msg['To'] = NOTIFICATION_EMAIL
100
+ msg.set_content(_scrub_dashes(body))
101
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode('ascii')
102
+ client = gmail_for('i@m13v.com')
103
+ client.service.users().messages().send(userId='me', body={'raw': raw}).execute()
104
+
105
+
106
+ def _format_subject(project: str, platform: str, severity: str,
107
+ available: int, total: int) -> str:
108
+ pct = f"{(available / total * 100):.0f}%" if total else "0%"
109
+ return f"[POOL {severity}] {project}/{platform}: {available}/{total} left ({pct})"
110
+
111
+
112
+ def _format_body(project: str, platform: str, severity: str,
113
+ available: int, total: int, ratio: float,
114
+ destinations: list[dict]) -> str:
115
+ lines = [
116
+ f"Severity: {severity}",
117
+ f"Project: {project}",
118
+ f"Platform: {platform}",
119
+ f"Available: {available}",
120
+ f"Total minted: {total}",
121
+ f"Remaining: {ratio*100:.1f}%",
122
+ "",
123
+ ]
124
+ if severity == 'CRITICAL':
125
+ lines += [
126
+ "The pool is exhausted. The next post for this (project, platform) will",
127
+ "return {ok: false, error: 'pool_exhausted'} and skip. Refill IMMEDIATELY:",
128
+ "",
129
+ ]
130
+ else:
131
+ lines += [
132
+ f"Pool has dropped under {int(WARN_REMAINING_RATIO*100)}% remaining. Schedule a refill",
133
+ "in the next few days to avoid hitting pool_exhausted on the next cycle.",
134
+ "",
135
+ ]
136
+ lines += [
137
+ "Refill commands (in ~/social-autoposter):",
138
+ " python3 scripts/mint_kent_pool.py # Kent clients (Runner/Agora/Podlog)",
139
+ " # for other external clients: extend mint_kent_pool.py SITE_CONFIG first",
140
+ "",
141
+ "Pool status snapshot:",
142
+ " python3 scripts/mint_kent_pool.py --status",
143
+ "",
144
+ ]
145
+ if destinations:
146
+ lines += ["Per-destination breakdown for this slice:"]
147
+ for d in destinations[:20]:
148
+ lines.append(
149
+ f" {d['minted_session'][-65:]:<65} "
150
+ f"avail={d['available']:>5} claimed={d['claimed']:>5}"
151
+ )
152
+ if len(destinations) > 20:
153
+ lines.append(f" ... and {len(destinations) - 20} more destinations")
154
+ lines.append("")
155
+ lines += [
156
+ f"Cooldown: {COOLDOWN_HOURS}h per (project, platform, severity)",
157
+ f"Re-fire: python3 scripts/check_external_pool_depth.py --force",
158
+ ]
159
+ return "\n".join(lines)
160
+
161
+
162
+ def _destinations_for_slice(project: str, platform: str) -> list[dict]:
163
+ resp = api_get(
164
+ "/api/v1/post-links/pool-depth",
165
+ query={
166
+ "project_name": project,
167
+ "platform": platform,
168
+ "with_destinations": "1",
169
+ },
170
+ )
171
+ return (resp.get("data") or {}).get("destinations") or []
172
+
173
+
174
+ def check(dry_run: bool = False, force: bool = False,
175
+ warn_ratio: float = WARN_REMAINING_RATIO,
176
+ limit: int | None = None) -> dict:
177
+ projects = _load_external_projects()
178
+ fired: list[dict] = []
179
+ skipped_cooldown: list[dict] = []
180
+ healthy: list[dict] = []
181
+ for p in projects:
182
+ project_name = p['name']
183
+ for platform in PLATFORMS:
184
+ available, total = _pool_depth(project_name, platform)
185
+ if total == 0:
186
+ continue
187
+ ratio = available / total if total > 0 else 0.0
188
+ if available == 0:
189
+ severity = 'CRITICAL'
190
+ elif ratio <= warn_ratio:
191
+ severity = 'WARN'
192
+ else:
193
+ healthy.append({
194
+ 'project': project_name, 'platform': platform,
195
+ 'available': available, 'total': total, 'ratio': ratio,
196
+ })
197
+ continue
198
+ key = (project_name, platform, severity)
199
+ if not force and _recent_alert_exists(*key):
200
+ skipped_cooldown.append({
201
+ 'project': project_name, 'platform': platform,
202
+ 'severity': severity, 'available': available, 'total': total,
203
+ })
204
+ continue
205
+ fired_row = {
206
+ 'project': project_name, 'platform': platform,
207
+ 'severity': severity, 'available': available, 'total': total,
208
+ 'ratio': ratio,
209
+ }
210
+ fired.append(fired_row)
211
+ if dry_run:
212
+ continue
213
+ if limit is not None and len(fired) > limit:
214
+ continue
215
+ destinations = _destinations_for_slice(project_name, platform)
216
+ subject = _format_subject(project_name, platform, severity, available, total)
217
+ body = _format_body(project_name, platform, severity, available, total,
218
+ ratio, destinations)
219
+ try:
220
+ _gmail_send(subject, body)
221
+ _record_alert(project_name, platform, severity,
222
+ available, total, ratio)
223
+ except Exception as e:
224
+ fired_row['send_error'] = str(e)
225
+ print(f"[pool-check] email send failed for {project_name}/{platform}: {e}",
226
+ file=sys.stderr)
227
+ return {
228
+ 'checked_at': datetime.now(timezone.utc).isoformat(),
229
+ 'fired': fired,
230
+ 'skipped_cooldown': skipped_cooldown,
231
+ 'healthy_count': len(healthy),
232
+ 'dry_run': dry_run,
233
+ }
234
+
235
+
236
+ def main():
237
+ ap = argparse.ArgumentParser(description=__doc__)
238
+ ap.add_argument('--dry-run', action='store_true',
239
+ help='compute and report, do not email or write state')
240
+ ap.add_argument('--force', action='store_true',
241
+ help='ignore 24h cooldown, re-fire matching alerts')
242
+ ap.add_argument('--warn-ratio', type=float, default=WARN_REMAINING_RATIO,
243
+ help=f'WARN threshold for available/total (default {WARN_REMAINING_RATIO})')
244
+ ap.add_argument('--limit', type=int, default=None,
245
+ help='cap the number of alert emails per run (smoke testing)')
246
+ args = ap.parse_args()
247
+ result = check(dry_run=args.dry_run, force=args.force,
248
+ warn_ratio=args.warn_ratio, limit=args.limit)
249
+ print(json.dumps(result, indent=2, default=str))
250
+
251
+
252
+ if __name__ == '__main__':
253
+ main()
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python3
2
+ """Check for web-chat threads with unread visitor messages (HTTP-only).
3
+
4
+ Reads GET /api/v1/web-chat/unread, which (1) recovers stuck threads and
5
+ (2) returns each unread, claimable thread with its first 200 messages embedded
6
+ in one round trip. Replaces the inline psycopg2 reads.
7
+
8
+ Prints a JSON array of:
9
+ { thread_id, project, visitor_email, visitor_name, unread, last_message,
10
+ page_url, messages: [{ id, text, sender, sender_name, created_at, read }] }
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18
+ from http_api import api_get
19
+
20
+
21
+ def main():
22
+ resp = api_get("/api/v1/web-chat/unread")
23
+ threads = (resp.get("data") or {}).get("threads") or []
24
+ print(json.dumps(threads))
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()