@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,81 @@
1
+ #!/usr/bin/env python3
2
+ """backfill_twitter_attempts_topic.py
3
+
4
+ Periodic UPDATE that fills `twitter_search_attempts.search_topic` from the
5
+ adjacent `twitter_candidates` rows once a scoring cycle finishes writing them.
6
+
7
+ HTTP-only (no DATABASE_URL): the two backfill passes run server-side behind
8
+ `POST /api/v1/twitter-search-attempts/backfill-topic`. This script is now a
9
+ thin trigger that POSTs the window and prints the rows-updated counts. The
10
+ published package carries no direct-DB dependency.
11
+
12
+ Why this backfill exists at all: `score_twitter_candidates.py` and the parent
13
+ `skill/run-twitter-cycle.sh` are both `chflags uchg` locked, and the canonical
14
+ SCAN_SCHEMA in the shell does not yet carry `search_topic` on each entry of
15
+ `queries_used` (so `log_twitter_search_attempts.py` cannot stamp it at INSERT
16
+ time). Until those locked files are extended, we backfill from the candidate
17
+ side, which DOES know the topic (set by `pick_search_topic.py` -> stamped onto
18
+ twitter_candidates.search_topic + search_attempt_id).
19
+
20
+ The endpoint runs two passes, both safe to rerun:
21
+
22
+ A) Direct join via search_attempt_id (covers non-dud attempts that produced
23
+ at least one candidate).
24
+
25
+ B) Fanout via (batch_id, project_name) -> covers dud attempts whose siblings
26
+ in the same cycle DID return candidates and therefore know the topic.
27
+ Skips ambiguous batches (more than one distinct topic) to avoid noise.
28
+
29
+ Fully-dud cycles stay NULL until the locked shell is extended; rare, and they
30
+ surface in the dashboard as a single "(no topic)" bucket per project.
31
+
32
+ Run from cron (launchd `com.m13v.social-twitter-attempt-topic-backfill`,
33
+ every 5 min) or directly:
34
+
35
+ python3 scripts/backfill_twitter_attempts_topic.py # 7d window
36
+ python3 scripts/backfill_twitter_attempts_topic.py --days 30
37
+ python3 scripts/backfill_twitter_attempts_topic.py --all # entire table
38
+ """
39
+ import argparse
40
+ import os
41
+ import sys
42
+ import time
43
+
44
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
45
+ from http_api import api_post, load_env # noqa: E402
46
+
47
+
48
+ def main():
49
+ p = argparse.ArgumentParser()
50
+ p.add_argument("--days", type=int, default=7,
51
+ help="Only backfill rows where ran_at >= NOW() - INTERVAL "
52
+ "'N days' (default 7). Cron path uses 14; ad-hoc "
53
+ "operators can widen.")
54
+ p.add_argument("--all", action="store_true",
55
+ help="Backfill the entire table; ignores --days.")
56
+ args = p.parse_args()
57
+
58
+ load_env()
59
+ t0 = time.time()
60
+
61
+ resp = api_post(
62
+ "/api/v1/twitter-search-attempts/backfill-topic",
63
+ {"days": int(args.days), "all": bool(args.all)},
64
+ )
65
+ data = resp.get("data") or {}
66
+ a_rows = data.get("pass_a", 0)
67
+ b_rows = data.get("pass_b", 0)
68
+ window = data.get("window", "all" if args.all else f"{args.days}d")
69
+
70
+ elapsed = time.time() - t0
71
+ print(
72
+ f"backfill_twitter_attempts_topic: "
73
+ f"pass_a={a_rows} pass_b={b_rows} window={window} "
74
+ f"elapsed={elapsed:.2f}s",
75
+ file=sys.stderr,
76
+ )
77
+ return 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ sys.exit(main())
@@ -0,0 +1,322 @@
1
+ """Backfill `posts` rows for Twitter replies that landed on x.com but failed
2
+ to be logged due to the `generation_trace exceeds 64 KB` API rejection
3
+ (2026-05-12 → 2026-05-13).
4
+
5
+ Background: when the generation_trace JSONB column landed on 2026-05-12, the
6
+ server-side cap (64 KB) was tighter than the actual size of Twitter cycle
7
+ traces (~85 KB). Every POST /api/v1/posts came back HTTP 400 bad_request, so
8
+ log_post.py returned no post_id and twitter_post_plan.py marked the
9
+ candidate `skipped` (correctly, to avoid double-posting on x.com) while
10
+ reporting `log_post_no_id` to the run summary. Net effect: the replies WERE
11
+ posted, but the database forgot them, dashboards showed posted=0, and
12
+ ~50 posts since 2026-05-12 have no row.
13
+
14
+ This script reconstructs the missing rows from `skill/logs/twitter-cycle-*.log`:
15
+
16
+ 1. Walks each cycle log.
17
+ 2. Finds each `[post] candidate N log_post.py did not return post_id` event.
18
+ 3. Walks backward to extract the reply JSON ({reply_url, final_text,
19
+ tweet_url, applied_campaigns}), the [gen] line (link_source), and the
20
+ [post] candidate line (project name via the surrounding context).
21
+ 4. Pulls the candidate's project_name / thread_author / thread_text /
22
+ engagement_style / language out of the structured_output.candidates
23
+ block emitted by the Phase 2b-prep Claude result (logged verbatim).
24
+ 5. Calls log_post.py without --generation-trace (sidesteps the 64 KB cap
25
+ entirely until the website cap-bump finishes deploying).
26
+
27
+ Idempotent: the API dedups on (platform, thread_url) so re-runs no-op for
28
+ already-backfilled posts.
29
+
30
+ Usage:
31
+ python3 scripts/backfill_twitter_log_post_no_id.py [--dry-run]
32
+ [--since 2026-05-12] [--logs-dir skill/logs]
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import os
40
+ import re
41
+ import subprocess
42
+ import sys
43
+ import time
44
+ from pathlib import Path
45
+ from typing import Optional
46
+
47
+ REPO = Path(__file__).resolve().parent.parent
48
+ LOG_POST = REPO / "scripts" / "log_post.py"
49
+
50
+ FAIL_LINE_RE = re.compile(
51
+ r"\[post\] candidate (\d+) log_post\.py did not return post_id"
52
+ )
53
+ GEN_LINE_RE = re.compile(
54
+ r"\[gen\] candidate_id=(\d+) link_url=\S+ source=(\S+)"
55
+ )
56
+ REPLY_STDOUT_START_RE = re.compile(r"\[post\]\[reply\.stdout\]")
57
+ POST_BLOCK_HDR_RE = re.compile(r"\[post\] candidate (\d+) -> posting")
58
+
59
+ # The Phase 2b-prep Claude response is logged verbatim as a single very long
60
+ # line containing a JSON envelope with the structured_output. We regex into
61
+ # it rather than json.loads-ing the whole thing because the line is wrapped
62
+ # inside the run log with other framing characters.
63
+ STRUCTURED_OUTPUT_RE = re.compile(
64
+ r'"structured_output":\s*({.*?"candidates":\s*\[.*?\])\s*(?:,"rejected"|,"queries_used"|})',
65
+ re.DOTALL,
66
+ )
67
+
68
+
69
+ def load_plan_candidates(log_text: str) -> dict[int, dict]:
70
+ """Pull the {candidate_id -> plan_entry} map out of the Claude prep
71
+ structured_output block embedded in the cycle log. Returns empty dict
72
+ if the block is missing or unparseable (older logs, partial runs).
73
+ """
74
+ out: dict[int, dict] = {}
75
+ # There can be more than one Claude turn in a cycle (scan + prep). The
76
+ # one we care about is the prep step whose candidates have reply_text.
77
+ # Walk every block; prefer the latest one with reply_text fields.
78
+ for m in re.finditer(
79
+ r'"structured_output":\s*\{(?P<body>.*?)\}\s*,\s*"terminal_reason"',
80
+ log_text, re.DOTALL,
81
+ ):
82
+ body = "{" + m.group("body") + "}"
83
+ try:
84
+ obj = json.loads(body)
85
+ except Exception:
86
+ continue
87
+ cands = obj.get("candidates") or []
88
+ for c in cands:
89
+ cid = c.get("candidate_id")
90
+ if cid is None:
91
+ continue
92
+ # Prep-phase entries have reply_text + engagement_style. Scan-phase
93
+ # entries don't. Prefer prep when both exist.
94
+ existing = out.get(cid)
95
+ if c.get("reply_text") or c.get("engagement_style"):
96
+ out[cid] = c
97
+ elif existing is None:
98
+ out[cid] = c
99
+ return out
100
+
101
+
102
+ def extract_reply_payload(lines: list[str], fail_idx: int) -> Optional[dict]:
103
+ """Walk back from the fail line to find the most recent reply.stdout
104
+ JSON block for this candidate. Returns parsed dict or None.
105
+ """
106
+ # The `[post][reply.stdout]` marker is followed by a JSON object spanning
107
+ # several lines. Find the marker, then read until the matching closing
108
+ # brace.
109
+ marker_idx = None
110
+ for i in range(fail_idx, max(-1, fail_idx - 200), -1):
111
+ if REPLY_STDOUT_START_RE.search(lines[i]):
112
+ marker_idx = i
113
+ break
114
+ if marker_idx is None:
115
+ return None
116
+ # JSON starts on the next line that begins with '{'
117
+ json_start = None
118
+ for i in range(marker_idx + 1, min(len(lines), marker_idx + 5)):
119
+ if lines[i].lstrip().startswith("{"):
120
+ json_start = i
121
+ break
122
+ if json_start is None:
123
+ return None
124
+ # Walk forward until depth returns to 0.
125
+ depth = 0
126
+ buf = []
127
+ for i in range(json_start, min(len(lines), json_start + 60)):
128
+ buf.append(lines[i])
129
+ for ch in lines[i]:
130
+ if ch == "{":
131
+ depth += 1
132
+ elif ch == "}":
133
+ depth -= 1
134
+ if depth == 0:
135
+ break
136
+ try:
137
+ return json.loads("\n".join(buf))
138
+ except Exception:
139
+ return None
140
+
141
+
142
+ def extract_link_source(lines: list[str], fail_idx: int, cid: int) -> Optional[str]:
143
+ """Walk back to find the [gen] candidate_id=N ... source=X line."""
144
+ for i in range(fail_idx, max(-1, fail_idx - 200), -1):
145
+ m = GEN_LINE_RE.search(lines[i])
146
+ if m and int(m.group(1)) == cid:
147
+ return m.group(2)
148
+ return None
149
+
150
+
151
+ def reconstruct_events(log_path: Path) -> list[dict]:
152
+ """Walk one cycle log and return a list of backfill records."""
153
+ try:
154
+ text = log_path.read_text(errors="replace")
155
+ except OSError:
156
+ return []
157
+ plan = load_plan_candidates(text)
158
+ lines = text.splitlines()
159
+ out = []
160
+ for idx, line in enumerate(lines):
161
+ m = FAIL_LINE_RE.search(line)
162
+ if not m:
163
+ continue
164
+ cid = int(m.group(1))
165
+ reply = extract_reply_payload(lines, idx) or {}
166
+ link_source = extract_link_source(lines, idx, cid)
167
+ entry = plan.get(cid) or {}
168
+ record = {
169
+ "log": str(log_path.name),
170
+ "candidate_id": cid,
171
+ "thread_url": reply.get("tweet_url") or entry.get("candidate_url") or "",
172
+ "our_url": reply.get("reply_url") or "",
173
+ "our_content": reply.get("final_text") or entry.get("reply_text") or "",
174
+ "project": entry.get("matched_project") or "",
175
+ "thread_author": entry.get("thread_author") or "",
176
+ "thread_title": entry.get("thread_text") or "",
177
+ "engagement_style": entry.get("engagement_style") or "",
178
+ "language": entry.get("language") or "",
179
+ "link_source": link_source or "",
180
+ "applied_campaigns": reply.get("applied_campaigns") or [],
181
+ }
182
+ out.append(record)
183
+ return out
184
+
185
+
186
+ def call_log_post(rec: dict) -> tuple[bool, str]:
187
+ """Invoke log_post.py for one backfill record. Returns (ok, message)."""
188
+ if not rec["thread_url"] or not rec["our_url"] or not rec["our_content"]:
189
+ return False, "missing required field(s)"
190
+ if not rec["project"]:
191
+ return False, "missing project (no plan data)"
192
+ args = [
193
+ sys.executable, str(LOG_POST),
194
+ "--platform", "twitter",
195
+ "--thread-url", rec["thread_url"],
196
+ "--our-url", rec["our_url"],
197
+ "--our-content", rec["our_content"],
198
+ "--project", rec["project"],
199
+ "--thread-author", rec["thread_author"],
200
+ "--thread-title", rec["thread_title"],
201
+ ]
202
+ if rec["engagement_style"]:
203
+ args += ["--engagement-style", rec["engagement_style"]]
204
+ if rec["language"]:
205
+ args += ["--language", rec["language"]]
206
+ if rec["link_source"]:
207
+ args += ["--link-source", rec["link_source"]]
208
+ # CRITICAL: do NOT pass --generation-trace. We are bypassing the cap
209
+ # entirely for backfill. The audit-trail loss for these ~50 rows is
210
+ # acceptable; new posts post-cap-bump will carry their trace normally.
211
+ try:
212
+ proc = subprocess.run(
213
+ args, capture_output=True, text=True, timeout=60,
214
+ env={**os.environ},
215
+ )
216
+ except subprocess.TimeoutExpired:
217
+ return False, "timeout"
218
+ stdout = (proc.stdout or "").strip()
219
+ stderr = (proc.stderr or "").strip()
220
+ # Parse last JSON line from stdout
221
+ last_json = None
222
+ for ln in stdout.splitlines()[::-1]:
223
+ ln = ln.strip()
224
+ if ln.startswith("{") and ln.endswith("}"):
225
+ try:
226
+ last_json = json.loads(ln)
227
+ break
228
+ except Exception:
229
+ continue
230
+ if last_json is None:
231
+ return False, f"no JSON in stdout. rc={proc.returncode} stderr={stderr[:200]!r}"
232
+ if last_json.get("error") == "DUPLICATE_THREAD":
233
+ return True, f"dup → existing post_id={last_json.get('existing_post_id')}"
234
+ if last_json.get("logged"):
235
+ return True, f"inserted post_id={last_json.get('post_id')}"
236
+ return False, f"unexpected log_post.py response: {last_json}"
237
+
238
+
239
+ def main() -> int:
240
+ parser = argparse.ArgumentParser(description=__doc__)
241
+ parser.add_argument("--logs-dir", default=str(REPO / "skill" / "logs"))
242
+ parser.add_argument("--since", default="2026-05-12",
243
+ help="date prefix; logs whose filenames sort >= "
244
+ "this string are included (YYYY-MM-DD).")
245
+ parser.add_argument("--dry-run", action="store_true",
246
+ help="print plan, do not call log_post.py")
247
+ parser.add_argument("--limit", type=int, default=0,
248
+ help="cap on number of records to backfill (0 = no cap)")
249
+ args = parser.parse_args()
250
+
251
+ logs_dir = Path(args.logs_dir)
252
+ if not logs_dir.is_dir():
253
+ print(f"ERROR: logs_dir not found: {logs_dir}", file=sys.stderr)
254
+ return 2
255
+
256
+ # Cycle log filenames look like: twitter-cycle-2026-05-13_083005.log
257
+ pat = re.compile(r"twitter-cycle-(\d{4}-\d{2}-\d{2})_")
258
+ log_files = []
259
+ for p in sorted(logs_dir.glob("twitter-cycle-*.log")):
260
+ m = pat.search(p.name)
261
+ if not m:
262
+ continue
263
+ if m.group(1) >= args.since:
264
+ log_files.append(p)
265
+ print(f"Scanning {len(log_files)} cycle logs since {args.since}…")
266
+
267
+ all_records = []
268
+ for p in log_files:
269
+ recs = reconstruct_events(p)
270
+ if recs:
271
+ print(f" {p.name}: {len(recs)} log_post_no_id event(s)")
272
+ all_records.extend(recs)
273
+
274
+ print(f"\nTotal backfill candidates: {len(all_records)}")
275
+
276
+ if not all_records:
277
+ return 0
278
+
279
+ # Dedup by our_url so we never insert the same reply twice within this run.
280
+ seen = set()
281
+ unique = []
282
+ for r in all_records:
283
+ key = r["our_url"]
284
+ if key in seen:
285
+ continue
286
+ seen.add(key)
287
+ unique.append(r)
288
+ if len(unique) != len(all_records):
289
+ print(f"De-duped by our_url: {len(all_records)} → {len(unique)}")
290
+
291
+ if args.limit > 0:
292
+ unique = unique[: args.limit]
293
+ print(f"Capped at --limit={args.limit}")
294
+
295
+ if args.dry_run:
296
+ for r in unique:
297
+ print(json.dumps({k: r[k] for k in (
298
+ "log", "candidate_id", "project", "thread_url", "our_url",
299
+ "engagement_style", "language", "link_source",
300
+ )}, ensure_ascii=False))
301
+ return 0
302
+
303
+ n_ok = n_fail = n_dup = 0
304
+ for r in unique:
305
+ ok, msg = call_log_post(r)
306
+ tag = "OK " if ok else "ERR"
307
+ if ok and "dup" in msg:
308
+ n_dup += 1
309
+ elif ok:
310
+ n_ok += 1
311
+ else:
312
+ n_fail += 1
313
+ print(f"{tag} cid={r['candidate_id']} project={r['project']!r} "
314
+ f"thread={r['thread_url']!s} | {msg}")
315
+ time.sleep(0.2) # gentle on the API
316
+
317
+ print(f"\nDone. inserted={n_ok} dup={n_dup} failed={n_fail}")
318
+ return 0 if n_fail == 0 else 1
319
+
320
+
321
+ if __name__ == "__main__":
322
+ sys.exit(main())
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ # bench_dashboard.sh
3
+ #
4
+ # Benchmark Social Autoposter dashboard endpoint latencies using curl + awk.
5
+ # Prints a table with p50/p95/min/max per endpoint.
6
+ #
7
+ # Env vars:
8
+ # BASE_URL default http://localhost:3141
9
+ # RUNS default 10 (requests per endpoint)
10
+ # CONCURRENCY default 1 (serial). If >1, uses background curls + wait.
11
+ #
12
+ # Always exits 0.
13
+
14
+ set -u
15
+
16
+ BASE_URL="${BASE_URL:-http://localhost:3141}"
17
+ RUNS="${RUNS:-10}"
18
+ CONCURRENCY="${CONCURRENCY:-1}"
19
+
20
+ ENDPOINTS=(
21
+ "/"
22
+ "/api/pending"
23
+ "/api/activity/stats?hours=24"
24
+ "/api/style/stats?hours=24"
25
+ "/api/status"
26
+ "/api/jobs"
27
+ )
28
+
29
+ TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"
30
+
31
+ printf 'bench_dashboard.sh time=%s base=%s runs=%s concurrency=%s\n' \
32
+ "$TIMESTAMP" "$BASE_URL" "$RUNS" "$CONCURRENCY"
33
+ printf '\n'
34
+
35
+ # Header row
36
+ printf '%-40s %-3s %-7s %-7s %-7s %-7s %s\n' \
37
+ "endpoint" "n" "p50" "p95" "min" "max" "codes"
38
+
39
+ TMPDIR="$(mktemp -d -t bench_dashboard.XXXXXX)"
40
+ trap 'rm -rf "$TMPDIR"' EXIT
41
+
42
+ # Run one curl, append "http_code time_total" to the given outfile.
43
+ # Args: URL OUTFILE
44
+ run_one() {
45
+ local url="$1"
46
+ local out="$2"
47
+ # -s silent, -o /dev/null discard body, -w format.
48
+ # On connection failure curl prints "000 0.000".
49
+ local line
50
+ line="$(curl -s -o /dev/null -w '%{http_code} %{time_total}\n' \
51
+ --max-time 60 "$url" 2>/dev/null || true)"
52
+ if [ -z "$line" ]; then
53
+ line="000 0.000"
54
+ fi
55
+ printf '%s\n' "$line" >> "$out"
56
+ }
57
+
58
+ for ep in "${ENDPOINTS[@]}"; do
59
+ url="${BASE_URL}${ep}"
60
+ outfile="${TMPDIR}/out.$$.$(echo "$ep" | tr -c 'A-Za-z0-9' '_')"
61
+ : > "$outfile"
62
+
63
+ if [ "$CONCURRENCY" -le 1 ]; then
64
+ i=0
65
+ while [ "$i" -lt "$RUNS" ]; do
66
+ run_one "$url" "$outfile"
67
+ i=$((i + 1))
68
+ done
69
+ else
70
+ # Launch in waves of CONCURRENCY until RUNS total are done.
71
+ launched=0
72
+ while [ "$launched" -lt "$RUNS" ]; do
73
+ wave=0
74
+ pids=""
75
+ while [ "$wave" -lt "$CONCURRENCY" ] && [ "$launched" -lt "$RUNS" ]; do
76
+ run_one "$url" "$outfile" &
77
+ pids="$pids $!"
78
+ wave=$((wave + 1))
79
+ launched=$((launched + 1))
80
+ done
81
+ # wait for this wave
82
+ for p in $pids; do
83
+ wait "$p" 2>/dev/null || true
84
+ done
85
+ done
86
+ fi
87
+
88
+ # Compute stats with awk.
89
+ # Input: lines of "CODE TIME". Output one line:
90
+ # count p50 p95 min max codes
91
+ awk -v ep="$ep" '
92
+ {
93
+ code = $1
94
+ t = $2 + 0
95
+ times[NR] = t
96
+ codes[code]++
97
+ n++
98
+ if (n == 1 || t < mn) mn = t
99
+ if (n == 1 || t > mx) mx = t
100
+ }
101
+ END {
102
+ if (n == 0) {
103
+ printf "%-40s %-3d %-7s %-7s %-7s %-7s %s\n", ep, 0, "-", "-", "-", "-", "none"
104
+ exit
105
+ }
106
+ # sort times ascending (insertion sort, fine for small n)
107
+ for (i = 2; i <= n; i++) {
108
+ v = times[i]; j = i - 1
109
+ while (j >= 1 && times[j] > v) { times[j+1] = times[j]; j-- }
110
+ times[j+1] = v
111
+ }
112
+ # p50 and p95 using nearest-rank, 1-indexed
113
+ p50_idx = int((50/100) * n + 0.9999); if (p50_idx < 1) p50_idx = 1; if (p50_idx > n) p50_idx = n
114
+ p95_idx = int((95/100) * n + 0.9999); if (p95_idx < 1) p95_idx = 1; if (p95_idx > n) p95_idx = n
115
+ p50 = times[p50_idx]
116
+ p95 = times[p95_idx]
117
+
118
+ # Build codes string sorted by code key
119
+ ncodes = 0
120
+ for (c in codes) { ncodes++; ck[ncodes] = c }
121
+ for (i = 2; i <= ncodes; i++) {
122
+ v = ck[i]; j = i - 1
123
+ while (j >= 1 && ck[j] > v) { ck[j+1] = ck[j]; j-- }
124
+ ck[j+1] = v
125
+ }
126
+ codes_str = ""
127
+ for (i = 1; i <= ncodes; i++) {
128
+ sep = (i == 1) ? "" : " "
129
+ codes_str = codes_str sep ck[i] "x" codes[ck[i]]
130
+ }
131
+
132
+ printf "%-40s %-3d %-7.3f %-7.3f %-7.3f %-7.3f %s\n", \
133
+ ep, n, p50, p95, mn, mx, codes_str
134
+ }
135
+ ' "$outfile"
136
+ done
137
+
138
+ exit 0
@@ -0,0 +1,39 @@
1
+ # exec'd inside browser-harness; helpers (goto_url, js, click_at_xy, type_text,
2
+ # press_key, page_info, wait_for_load) are available as globals.
3
+ import time as _t, json as _j
4
+ def send(A, MSG, UNIQ):
5
+ goto_url("https://x.com/"+A); wait_for_load(); _t.sleep(2.5)
6
+ prof = js(r"""
7
+ (() => {
8
+ const nameEl=document.querySelector('[data-testid="UserName"]');
9
+ const bioEl=document.querySelector('[data-testid="UserDescription"]');
10
+ let followers=null;
11
+ document.querySelectorAll('a[href$="/verified_followers"],a[href$="/followers"]').forEach(a=>{followers=a.innerText.replace(/\n/g,' ');});
12
+ const tweets=[...document.querySelectorAll('[data-testid="tweetText"]')].slice(0,3).map(t=>t.innerText.slice(0,120).replace(/\n/g,' '));
13
+ const b=document.querySelector('[data-testid="sendDMFromProfile"]');
14
+ let msgRect=null; if(b){const r=b.getBoundingClientRect(); msgRect={x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}
15
+ const suspended=/This account doesn|Account suspended|Hmm.*went wrong|Caution/i.test(document.body.innerText.slice(0,300));
16
+ return {name:nameEl?nameEl.innerText.replace(/\n/g,' | '):null, bio:bioEl?bioEl.innerText.replace(/\n/g,' '):null, followers, tweets, hasMsg:!!b, msgRect, suspended};
17
+ })()
18
+ """)
19
+ prof['tweets']=(prof.get('tweets') or [])[:1]
20
+ prof['bio']=(prof.get('bio') or '')[:140]
21
+ out={"prof":prof}
22
+ if prof.get('suspended') or not prof.get('hasMsg'):
23
+ out["status"]="no_dm"; print(_j.dumps(out,ensure_ascii=True)[:2400]); return
24
+ click_at_xy(prof['msgRect']['x'],prof['msgRect']['y']); _t.sleep(3)
25
+ rect=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); if(!t)return null; const r=t.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()""")
26
+ if not rect:
27
+ out["status"]="no_composer"; out["url"]=page_info().get('url'); print(_j.dumps(out,ensure_ascii=True)[:2400]); return
28
+ click_at_xy(rect['x'],rect['y']); _t.sleep(0.6)
29
+ type_text(MSG); _t.sleep(0.9)
30
+ val=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); return t?t.value:null;})()""")
31
+ if (val or "").strip()!=MSG:
32
+ out["status"]="type_mismatch"; out["len"]=len(val or ""); out["url"]=page_info().get('url'); print(_j.dumps(out,ensure_ascii=True)[:2400]); return
33
+ press_key("Enter"); _t.sleep(2.6)
34
+ chk=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); const body=document.body.innerText; return {cleared:t?t.value.trim()==='':null, url:location.href, bodyHasHandle:body.includes('@__A__')};})()""".replace('__A__',A))
35
+ full=js(r"""(()=>document.body.innerText)()""") or ""
36
+ chk["hasPhrase"]= UNIQ in full
37
+ out["status"]="sent" if (chk.get('cleared') and chk.get('hasPhrase')) else "send_unverified"
38
+ out["url"]=chk.get('url'); out["verify"]=chk
39
+ print(_j.dumps(out,ensure_ascii=True)[:2400])