@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,651 @@
1
+ #!/usr/bin/env python3
2
+ """Log a posted comment/reply to the database.
3
+
4
+ Single tool for all platforms. Enforces:
5
+ - status='active' for successful posts
6
+ - our_url must start with http for successful posts (validated)
7
+ - dedup on thread_url per platform
8
+
9
+ Usage (INSERT — default mode):
10
+ python3 scripts/log_post.py \\
11
+ --platform reddit \\
12
+ --thread-url URL \\
13
+ --our-url URL \\
14
+ --our-content TEXT \\
15
+ --project PROJECT \\
16
+ --thread-author AUTHOR \\
17
+ --thread-title TITLE \\
18
+ [--account ACCOUNT] \\
19
+ [--engagement-style STYLE] \\
20
+ [--language LANG]
21
+
22
+ Usage (REJECTED — record a server-rejected attempt):
23
+ python3 scripts/log_post.py --rejected \\
24
+ --platform linkedin \\
25
+ --thread-url URL \\
26
+ --our-content TEXT \\
27
+ --project PROJECT \\
28
+ [--rejection-reason TEXT] \\
29
+ [--network-response TEXT]
30
+
31
+ Inserts with status='rejected_by_platform'. Skips our_url validation
32
+ (no permalink exists). Counts toward dedup so we don't retry the same
33
+ thread. rejection-reason and network-response go into source_summary.
34
+
35
+ Usage (UPDATE — record a self-reply / link follow-up on an existing post):
36
+ python3 scripts/log_post.py --mark-self-reply \\
37
+ --post-id 12345 \\
38
+ --self-reply-url URL \\
39
+ --self-reply-content TEXT
40
+
41
+ Writes to posts.link_edited_at / link_edit_content so the
42
+ link-edit-* sweeps skip this row on the next pass.
43
+
44
+ Output (JSON):
45
+ {"logged": true, "post_id": 12345}
46
+ {"rejected": true, "post_id": 12345}
47
+ {"marked": true, "post_id": 12345}
48
+ {"error": "DUPLICATE_THREAD", ...}
49
+ {"error": "INVALID_URL", ...}
50
+ {"error": "POST_NOT_FOUND", ...}
51
+ """
52
+
53
+ import argparse
54
+ import json
55
+ import os
56
+ import re
57
+ import sys
58
+ import urllib.parse
59
+
60
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
61
+ import http_api
62
+
63
+ # --- API error-envelope helpers (2026-06-02) -------------------------------
64
+ # The API returns failures as a NESTED object: {"ok": false, "error": {"code",
65
+ # "message", "details"}} (see social-autoposter-website response.ts). http_api
66
+ # may also surface a FLAT {"error": "conflict"} on a 409 it couldn't parse. The
67
+ # old `resp.get("error") in (...)` string check missed the nested shape, so a
68
+ # duplicate_thread 409 fell through and printed {"logged": true, "post_id":
69
+ # null} -- a false success that looked like a logging gap. These helpers read
70
+ # either shape so dedups are recognized and reported correctly.
71
+ def _api_error_code(resp):
72
+ e = (resp or {}).get("error")
73
+ if isinstance(e, dict):
74
+ return e.get("code")
75
+ return e # flat string or None
76
+
77
+ def _api_error_detail(resp, key):
78
+ e = (resp or {}).get("error")
79
+ if isinstance(e, dict):
80
+ d = e.get("details")
81
+ if isinstance(d, dict) and d.get(key) is not None:
82
+ return d.get(key)
83
+ return (resp or {}).get(key)
84
+
85
+ import linkedin_url as li_url
86
+ from db import load_env
87
+ from twitter_account import resolve_handle as resolve_twitter_handle
88
+ from version import read_version as read_autoposter_version
89
+
90
+ # Engagement-style enforcement (2026-05-31 LinkedIn alignment): the LinkedIn
91
+ # post path goes straight through log_post.py (no candidate/plan pipeline like
92
+ # Twitter's twitter_post_plan.py), so the picker-coercion engine has to live
93
+ # here. When the caller passes --assigned-style/--assigned-mode (sourced from
94
+ # saps_pick_style in run-linkedin.sh), we call validate_or_register exactly
95
+ # like twitter_post_plan.py::post_one so (a) USE-mode drift coerces back to the
96
+ # assigned style and (b) INVENT-mode inventions land in
97
+ # engagement_styles_registry via the /api/v1/engagement-styles/registry POST.
98
+ # Soft import so the post path still runs if the module is unavailable; we fall
99
+ # back to the raw --engagement-style string in that case.
100
+ try:
101
+ from engagement_styles import validate_or_register # noqa: E402
102
+ except Exception:
103
+ validate_or_register = None # type: ignore[assignment]
104
+
105
+ URN_ID_RE = re.compile(r"\b(\d{16,19})\b")
106
+
107
+
108
+ def parse_urn_ids(*sources):
109
+ """Extract all 16-19-digit URN IDs from the given strings, dedupe,
110
+ preserve insertion order. Used to merge --urns CLI input with IDs
111
+ found in thread_url / our_url so we always store the full URN set
112
+ we know about for a LinkedIn post."""
113
+ seen = []
114
+ for s in sources:
115
+ if not s:
116
+ continue
117
+ for m in URN_ID_RE.finditer(s):
118
+ v = m.group(1)
119
+ if v not in seen:
120
+ seen.append(v)
121
+ return seen
122
+
123
+ VALID_PLATFORMS = ("reddit", "twitter", "linkedin", "github_issues", "moltbook")
124
+
125
+ # Maps log_post's platform strings to account_resolver's platform keys.
126
+ # (log_post uses "github_issues"; account_resolver uses "github".)
127
+ _RESOLVER_PLATFORM = {
128
+ "twitter": "twitter",
129
+ "reddit": "reddit",
130
+ "linkedin": "linkedin",
131
+ "github_issues": "github",
132
+ "moltbook": "moltbook",
133
+ }
134
+
135
+
136
+ def _resolve_default_account(platform: str) -> str:
137
+ """Return the configured account handle for `platform` on this machine.
138
+
139
+ Resolved ONLY from env (`AUTOPOSTER_<PLATFORM>_*`) or config.json
140
+ (`accounts.<platform>.*`) via account_resolver. There are NO hardcoded
141
+ handle fallbacks: a misconfigured install must never silently post under
142
+ another person's identity. The old per-platform defaults
143
+ (m13v_/Deep_Ad1959/Matthew Diakonov/m13v/matthew-autoposter) did exactly
144
+ that, stamping every unconfigured install's rows with the repo owner's
145
+ handle and polluting the shared DB across accounts.
146
+
147
+ Returns "" when nothing is configured; the caller's
148
+ `args.account or _resolve_default_account(...)` chain still lets an
149
+ explicit `--account` flag win, and an empty value surfaces the misconfig
150
+ instead of impersonating someone.
151
+ """
152
+ try:
153
+ import account_resolver
154
+ return account_resolver.resolve(
155
+ _RESOLVER_PLATFORM.get(platform, platform)
156
+ ) or ""
157
+ except Exception:
158
+ return ""
159
+
160
+
161
+ def coerce_engagement_style(args):
162
+ """Run the picker-coercion engine and return the style to log.
163
+
164
+ Shared by INSERT mode and --rejected mode so a server-rejected attempt
165
+ records the same coerced/assigned style as a successful one (otherwise
166
+ INVENT-mode model names leak onto rejected rows and pollute the per-style
167
+ report). When the caller passed --assigned-style/--assigned-mode (from
168
+ saps_pick_style in run-linkedin.sh), call validate_or_register exactly
169
+ like twitter_post_plan.py::post_one:
170
+ - USE-mode drift coerces back to the assigned name
171
+ - INVENT-mode + well-formed --new-style registers in the registry
172
+ Falls back to the raw --engagement-style on any error / missing module so
173
+ a registry hiccup never blocks the write. Returns the style string (or
174
+ None) to use for this row.
175
+ """
176
+ raw_style = (args.engagement_style or "").strip() or None
177
+ if validate_or_register is None or not raw_style:
178
+ return raw_style
179
+ if not (args.assigned_style or args.assigned_mode):
180
+ return raw_style
181
+
182
+ new_style_block = None
183
+ if args.new_style:
184
+ try:
185
+ parsed = json.loads(args.new_style)
186
+ if isinstance(parsed, dict):
187
+ new_style_block = parsed
188
+ except json.JSONDecodeError as e:
189
+ print(json.dumps({
190
+ "warning": "NEW_STYLE_PARSE_FAILED",
191
+ "message": f"could not parse --new-style JSON: {e}",
192
+ }), file=sys.stderr)
193
+
194
+ decision = {
195
+ "engagement_style": raw_style,
196
+ **({"new_style": new_style_block} if new_style_block else {}),
197
+ }
198
+ try:
199
+ coerced_style, action = validate_or_register(
200
+ decision,
201
+ source_post={
202
+ "platform": args.platform,
203
+ "post_url": getattr(args, "our_url", None) or args.thread_url,
204
+ "post_id": None,
205
+ "model": None,
206
+ },
207
+ assigned_style=(args.assigned_style or None),
208
+ assigned_mode=(args.assigned_mode or None),
209
+ )
210
+ except Exception as e:
211
+ print(f"[log_post] validate_or_register raised {e!r}; "
212
+ f"falling back to raw style={raw_style!r}", file=sys.stderr)
213
+ return raw_style
214
+
215
+ if action == "coerced" and coerced_style != raw_style:
216
+ print(f"[log_post] engagement_style coerced {raw_style!r} -> "
217
+ f"{coerced_style!r} (assigned={args.assigned_style!r})",
218
+ file=sys.stderr)
219
+ elif action == "registered":
220
+ print(f"[log_post] registered new engagement_style "
221
+ f"{coerced_style!r} into engagement_styles_registry",
222
+ file=sys.stderr)
223
+ # coerced_style is None only on "rejected" (unknown style, no usable
224
+ # new_style). Keep the raw style so the row still logs a non-null style.
225
+ return (coerced_style or raw_style or "").strip() or None
226
+
227
+
228
+ def mark_self_reply(args):
229
+ if args.post_id is None or not args.self_reply_url or args.self_reply_content is None:
230
+ print(json.dumps({
231
+ "error": "MISSING_ARGS",
232
+ "message": "--mark-self-reply requires --post-id, --self-reply-url, --self-reply-content",
233
+ }))
234
+ sys.exit(1)
235
+ if not args.self_reply_url.startswith("http"):
236
+ print(json.dumps({
237
+ "error": "INVALID_URL",
238
+ "message": f"self-reply-url must start with http, got: {args.self_reply_url[:50]}",
239
+ }))
240
+ sys.exit(1)
241
+
242
+ load_env()
243
+ http_api.api_patch(f"/api/v1/posts/{args.post_id}", {
244
+ "self_reply_url": args.self_reply_url,
245
+ "self_reply_content": args.self_reply_content,
246
+ })
247
+ print(json.dumps({"marked": True, "post_id": args.post_id}))
248
+
249
+
250
+ def log_rejected(args):
251
+ """Record a comment attempt that the platform rejected server-side.
252
+
253
+ Writes status='rejected_by_platform' so dedup blocks retries on the same
254
+ thread, and stashes the rejection reason + network response in
255
+ source_summary for diagnostics.
256
+ """
257
+ missing = [f for f in ("platform", "thread_url", "our_content", "project")
258
+ if getattr(args, f) is None]
259
+ if missing:
260
+ print(json.dumps({
261
+ "error": "MISSING_ARGS",
262
+ "message": f"--rejected requires: {', '.join('--' + m.replace('_', '-') for m in missing)}",
263
+ }))
264
+ sys.exit(1)
265
+
266
+ account = args.account or _resolve_default_account(args.platform)
267
+
268
+ # Engagement-style enforcement (2026-05-31 LinkedIn alignment): coerce the
269
+ # model's style back to the picker assignment (USE) or register the
270
+ # invention (INVENT) before the INSERT, so server-rejected rows record the
271
+ # same canonical style as successful ones instead of leaking one-off
272
+ # invented names into the per-style report. See coerce_engagement_style().
273
+ args.engagement_style = coerce_engagement_style(args)
274
+
275
+ summary_parts = []
276
+ if args.rejection_reason:
277
+ summary_parts.append(f"REASON: {args.rejection_reason}")
278
+ if args.network_response:
279
+ summary_parts.append(f"NETWORK: {args.network_response}")
280
+ summary = "\n".join(summary_parts) if summary_parts else "rejected_by_platform"
281
+
282
+ load_env()
283
+ claude_session_id = os.environ.get("CLAUDE_SESSION_ID") or None
284
+
285
+ urn_ids = []
286
+ if args.platform == "linkedin":
287
+ urn_ids = parse_urn_ids(args.urns, args.thread_url, args.network_response)
288
+
289
+ body = {
290
+ "platform": args.platform,
291
+ "thread_url": args.thread_url,
292
+ "our_content": args.our_content,
293
+ "project": args.project,
294
+ "status": "rejected_by_platform",
295
+ "thread_author": args.thread_author or "",
296
+ "thread_title": args.thread_title or "",
297
+ "thread_content": args.thread_content or "",
298
+ "our_account": account,
299
+ "source_summary": summary,
300
+ }
301
+ if args.engagement_style:
302
+ body["engagement_style"] = args.engagement_style
303
+ if args.search_topic:
304
+ body["search_topic"] = args.search_topic
305
+ if args.language:
306
+ body["language"] = args.language
307
+ if claude_session_id:
308
+ body["claude_session_id"] = claude_session_id
309
+ if urn_ids:
310
+ body["urns"] = urn_ids
311
+ # autoposter_version: stamped on every write so we can attribute
312
+ # engagement back to the release of the autoposter code that produced
313
+ # this row. None when package.json + env are both missing; API stores
314
+ # NULL in that case (doesn't block the insert).
315
+ autoposter_version = read_autoposter_version()
316
+ if autoposter_version:
317
+ body["autoposter_version"] = autoposter_version
318
+
319
+ resp = http_api.api_post("/api/v1/posts", body, ok_on_conflict=True)
320
+ if resp and _api_error_code(resp) in ("duplicate_thread", "conflict"):
321
+ print(json.dumps({
322
+ "error": "DUPLICATE_THREAD",
323
+ "message": "Already have a row for this thread",
324
+ "existing_post_id": _api_error_detail(resp, "existing_post_id"),
325
+ }))
326
+ return
327
+ # See note in main() about the resp.data.post.id shape.
328
+ data = (resp or {}).get("data") or {}
329
+ post_obj = data.get("post") or (resp or {}).get("post") or {}
330
+ post_id = post_obj.get("id")
331
+ print(json.dumps({"rejected": True, "post_id": post_id, "urns": urn_ids}))
332
+
333
+
334
+ def main():
335
+ parser = argparse.ArgumentParser(description="Log a posted comment to the database")
336
+ parser.add_argument("--mark-self-reply", action="store_true",
337
+ help="UPDATE mode: mark link_edited_at on an existing post. "
338
+ "Requires --post-id, --self-reply-url, --self-reply-content.")
339
+ parser.add_argument("--rejected", action="store_true",
340
+ help="REJECTED mode: record a server-rejected attempt with "
341
+ "status='rejected_by_platform'. Skips our_url validation. "
342
+ "Use when the platform silently swallowed the comment.")
343
+ parser.add_argument("--rejection-reason", default=None,
344
+ help="Brief reason text (e.g. 'TOAST: comment could not be created'). "
345
+ "Goes into source_summary.")
346
+ parser.add_argument("--network-response", default=None,
347
+ help="Captured XHR response from the comment-create endpoint. "
348
+ "Goes into source_summary (truncated to 4000 chars).")
349
+ parser.add_argument("--post-id", type=int, default=None,
350
+ help="posts.id to update (only with --mark-self-reply)")
351
+ parser.add_argument("--self-reply-url", default=None,
352
+ help="URL of the self-reply that carries the project link")
353
+ parser.add_argument("--self-reply-content", default=None,
354
+ help="Text of the self-reply (goes into link_edit_content)")
355
+ parser.add_argument("--platform", choices=VALID_PLATFORMS)
356
+ parser.add_argument("--thread-url")
357
+ parser.add_argument("--our-url",
358
+ help="Permalink to our posted comment (must start with http)")
359
+ parser.add_argument("--our-content")
360
+ parser.add_argument("--project")
361
+ parser.add_argument("--thread-author", default="")
362
+ parser.add_argument("--thread-title", default="")
363
+ parser.add_argument("--thread-content", default="",
364
+ help="Body text of the original thread/post we're "
365
+ "replying to. Stored in posts.thread_content and "
366
+ "surfaced on the public dashboard so visitors see "
367
+ "the conversation context our comment lives in. "
368
+ "Capped at 4000 chars by the API.")
369
+ parser.add_argument("--account", default=None,
370
+ help="Override default account for the platform")
371
+ parser.add_argument("--engagement-style", default=None,
372
+ help="Tone style (e.g. critic, storyteller). Separate from "
373
+ "--is-recommendation, which is intent.")
374
+ parser.add_argument("--assigned-style", default=None,
375
+ help="The engagement style the programmatic picker "
376
+ "(saps_pick_style / pick_style_for_post) assigned for "
377
+ "this post. When present alongside --assigned-mode, "
378
+ "log_post runs validate_or_register so USE-mode drift "
379
+ "is coerced back to this name and INVENT-mode names "
380
+ "register in engagement_styles_registry. Mirrors "
381
+ "Twitter's --assigned-style on log_draft.py. Empty on "
382
+ "INVENT mode (picker assigns no concrete name).")
383
+ parser.add_argument("--assigned-mode", default=None,
384
+ help="Picker mode for this post: 'use' (a concrete style "
385
+ "was assigned, drift coerces back) or 'invent' (model "
386
+ "creates a new snake_case style + --new-style block). "
387
+ "Drives validate_or_register's enforcement branch.")
388
+ parser.add_argument("--new-style", default=None,
389
+ help="JSON object describing a model-invented style, REQUIRED "
390
+ "iff --assigned-mode=invent and --engagement-style is a "
391
+ "new name not in the registry. Shape mirrors "
392
+ "engagement_styles.py::_REQUIRED_NEW_STYLE_FIELDS: "
393
+ "{description, example, why_existing_didnt_fit, "
394
+ "note?}. Passed through to validate_or_register so the "
395
+ "invention lands in engagement_styles_registry.")
396
+ parser.add_argument("--search-topic", default=None,
397
+ help="Topic seed from the project's search_topics list "
398
+ "(or a model-invented variant) that surfaced this "
399
+ "thread. Stamped on posts.search_topic so "
400
+ "top_search_topics.py can aggregate per-topic "
401
+ "conversion. For Twitter this should be copied "
402
+ "from twitter_candidates.search_topic; Reddit and "
403
+ "GitHub already populate this field via their own "
404
+ "log-post wrappers.")
405
+ parser.add_argument("--is-recommendation", action="store_true",
406
+ help="Mark this post as a project mention/recommendation. "
407
+ "Composes with --engagement-style; tone and intent are "
408
+ "independent dimensions.")
409
+ parser.add_argument("--language", default=None,
410
+ help="ISO 639-1 language code (e.g. en, ja, zh, es)")
411
+ parser.add_argument("--link-source", default=None,
412
+ help="How the link in our_content was sourced: "
413
+ "seo_page | plain_url_ab_skip | plain_url_no_lp | "
414
+ "plain_url_fallback:<reason> | empty[_*]. "
415
+ "Used to A/B compare engagement between the "
416
+ "page-gen and plain-URL lanes on Twitter.")
417
+ parser.add_argument("--tail-link-variant", default=None,
418
+ help="Tail-link AB test arm for Twitter posts: "
419
+ "'link' (reply includes bridge sentence + URL) or "
420
+ "'no_link' (reply posted without any link tail). "
421
+ "NULL for non-Twitter posts and rows pre-dating "
422
+ "the experiment. Stored in posts.tail_link_variant.")
423
+ parser.add_argument("--target-chars", type=int, default=None,
424
+ help="Snapshot of the assigned engagement style's "
425
+ "target comment length (chars) at post time. "
426
+ "Frozen onto posts.target_chars so "
427
+ "style_length_report can compare realized-vs-target "
428
+ "length immune to later registry drift. Resolved by "
429
+ "the caller (twitter_post_plan.py) from the final "
430
+ "coerced style via the registry. NULL leaves the "
431
+ "column empty; the report falls back to the live "
432
+ "registry target for NULL rows.")
433
+ parser.add_argument("--length-arm", default=None,
434
+ help="Historical Twitter length-control A/B arm. The live "
435
+ "experiment concluded 2026-06-04 and production no "
436
+ "longer passes this flag; keep it for old rows and "
437
+ "manual/backfill writes to posts.length_arm. Expected "
438
+ "values: 'treatment' or 'control'.")
439
+ parser.add_argument("--draft-prompt-variant", default=None,
440
+ help="Draft-prompt A/B arm for Twitter posts: 'treatment' "
441
+ "(decoupled draft directive; reply stands on its own, "
442
+ "no forced concede->pivot to product) or 'control' "
443
+ "(current directive). Assigned per cycle in "
444
+ "run-twitter-cycle.sh and read from S4L_DRAFT_PROMPT_VARIANT "
445
+ "by twitter_post_plan.py. NULL for non-Twitter rows and "
446
+ "rows pre-dating the experiment. Stored in "
447
+ "posts.draft_prompt_variant.")
448
+ parser.add_argument("--urns", default=None,
449
+ help="LinkedIn-only: comma- or whitespace-separated list "
450
+ "of 16-19 digit URN IDs that identify this post "
451
+ "(activity, ugcPost, share). Pass everything you "
452
+ "captured from the createComment network response. "
453
+ "log_post.py merges these with IDs extracted from "
454
+ "thread_url and our_url before INSERT, so dedup "
455
+ "via posts.urns catches future cross-URN collisions.")
456
+ parser.add_argument("--generation-trace", default=None,
457
+ help="Path to a JSON file with the few-shot context "
458
+ "Claude saw before drafting this post. Stored in "
459
+ "posts.generation_trace JSONB so a later audit "
460
+ "can reconstruct 'which examples produced this "
461
+ "output?'. Pass the file path (NOT inline JSON) "
462
+ "to keep argv short and avoid shell-escape pain. "
463
+ "Capped at 64 KB by the API. See "
464
+ "migrations/2026-05-12_generation_trace.sql.")
465
+ parser.add_argument("--thread-engagement", default=None,
466
+ help="JSON string snapshot of the original thread's "
467
+ "engagement at scrape time. Shape: "
468
+ "{\"likes\":N,\"retweets\":N,\"replies\":N,"
469
+ "\"views\":N,\"bookmarks\":N,\"snapshot_at\":\"...\"}. "
470
+ "Stored verbatim in posts.thread_engagement (TEXT). "
471
+ "No live refresh, no extra API calls; whatever the "
472
+ "candidate row already had under *_t0 is what gets "
473
+ "recorded. Capped at 2 KB by the API.")
474
+ parser.add_argument("--thread-media", default=None,
475
+ help="JSON array snapshot of the original thread's media "
476
+ "([{\"url\":...,\"alt\":...,\"type\":\"image|video|gif|card\"}]) "
477
+ "captured at draft time. Stored in posts.thread_media "
478
+ "(JSONB) as the immutable record of what the thread "
479
+ "visually showed when we replied. An empty array [] is "
480
+ "valid (captured-none). Omitted/None leaves the column "
481
+ "NULL (never captured). 2026-06-03 thread-media feature.")
482
+ args = parser.parse_args()
483
+
484
+ if args.mark_self_reply:
485
+ mark_self_reply(args)
486
+ return
487
+
488
+ if args.rejected:
489
+ log_rejected(args)
490
+ return
491
+
492
+ # INSERT mode — enforce required fields that argparse can't conditionally require.
493
+ missing = [f for f in ("platform", "thread_url", "our_url", "our_content", "project")
494
+ if getattr(args, f) is None]
495
+ if missing:
496
+ print(json.dumps({
497
+ "error": "MISSING_ARGS",
498
+ "message": f"INSERT mode requires: {', '.join('--' + m.replace('_', '-') for m in missing)}",
499
+ }))
500
+ sys.exit(1)
501
+
502
+ # Validate our_url
503
+ if not args.our_url.startswith("http"):
504
+ print(json.dumps({
505
+ "error": "INVALID_URL",
506
+ "message": f"our_url must start with http, got: {args.our_url[:50]}",
507
+ }))
508
+ sys.exit(1)
509
+
510
+ account = args.account or _resolve_default_account(args.platform)
511
+
512
+ # Engagement-style enforcement (2026-05-31 LinkedIn alignment): coerce the
513
+ # model's style back to the picker assignment (USE) or register the
514
+ # invention (INVENT) before the INSERT. See coerce_engagement_style().
515
+ args.engagement_style = coerce_engagement_style(args)
516
+
517
+ # LinkedIn: same post surfaces under multiple URL shapes (/feed/update/
518
+ # vs /posts/...-share-...) with different numeric URNs. Canonicalize
519
+ # our_url to /feed/update/urn:li:activity:<id>/ so the comment-permalink
520
+ # captured after posting drops its commentUrn query string.
521
+ urn_ids = []
522
+ if args.platform == "linkedin":
523
+ # Preserve a ?commentUrn= query (it identifies OUR engagement-comment)
524
+ # across canonicalization. canonicalize() runs ACTIVITY_URN_RE over the
525
+ # whole URL and, when the URL carries
526
+ # ?commentUrn=urn:li:comment:(activity:<parent>,<cid>)
527
+ # it matches the INNER parent activity and collapses the entire URL to
528
+ # /feed/update/urn:li:activity:<parent>/, dropping both the base post
529
+ # URN and our comment id. That breaks the stats matcher
530
+ # (update_linkedin_stats_from_feed.py keys on the numeric comment id
531
+ # inside commentUrn). Fix: canonicalize the PATH-ONLY base, then
532
+ # re-attach the original commentUrn so the stored our_url keeps it.
533
+ _split = urllib.parse.urlsplit(args.our_url or "")
534
+ _qs = urllib.parse.parse_qs(_split.query)
535
+ _comment_urn = (_qs.get("commentUrn") or [None])[0]
536
+ _base = urllib.parse.urlunsplit(
537
+ (_split.scheme, _split.netloc, _split.path, "", "")
538
+ )
539
+ _canon = li_url.canonicalize(_base)
540
+ if _comment_urn:
541
+ _sep = "&" if "?" in _canon else "?"
542
+ args.our_url = (
543
+ _canon + _sep + "commentUrn="
544
+ + urllib.parse.quote(_comment_urn, safe="")
545
+ )
546
+ else:
547
+ args.our_url = _canon
548
+ # Build the full URN-ID set for this post: --urns input plus
549
+ # everything we can extract from thread_url and our_url. Stored in
550
+ # posts.urns so future dedup queries catch any URN form (activity,
551
+ # ugcPost, share) regardless of which one the candidate-page DOM
552
+ # renders. Without this, the search-page only exposes the ugcPost
553
+ # URN while we stored only the activity URN, so the cross-URN
554
+ # collision check missed and we double-posted.
555
+ urn_ids = parse_urn_ids(args.urns, args.thread_url, args.our_url)
556
+
557
+ load_env()
558
+ claude_session_id = os.environ.get("CLAUDE_SESSION_ID") or None
559
+
560
+ body = {
561
+ "platform": args.platform,
562
+ "thread_url": args.thread_url,
563
+ "our_url": args.our_url,
564
+ "our_content": args.our_content,
565
+ "project": args.project,
566
+ "thread_author": args.thread_author or "",
567
+ "thread_title": args.thread_title or "",
568
+ "thread_content": args.thread_content or "",
569
+ "our_account": account,
570
+ "is_recommendation": bool(args.is_recommendation),
571
+ }
572
+ if args.engagement_style:
573
+ body["engagement_style"] = args.engagement_style
574
+ if args.search_topic:
575
+ body["search_topic"] = args.search_topic
576
+ if args.language:
577
+ body["language"] = args.language
578
+ if claude_session_id:
579
+ body["claude_session_id"] = claude_session_id
580
+ if urn_ids:
581
+ body["urns"] = urn_ids
582
+ if args.link_source:
583
+ body["link_source"] = args.link_source
584
+ if args.tail_link_variant:
585
+ body["tail_link_variant"] = args.tail_link_variant
586
+ if args.draft_prompt_variant:
587
+ body["draft_prompt_variant"] = args.draft_prompt_variant
588
+ if args.target_chars:
589
+ body["target_chars"] = args.target_chars
590
+ if args.length_arm:
591
+ body["length_arm"] = args.length_arm
592
+ if args.thread_engagement:
593
+ body["thread_engagement"] = args.thread_engagement
594
+ # Thread media snapshot (2026-06-03): the media of the thread we replied to,
595
+ # frozen onto posts.thread_media as an immutable audit record. Read from the
596
+ # candidate row by twitter_post_plan.py and forwarded here as a JSON array
597
+ # string. Parse defensively: a malformed value must NOT block the post, so on
598
+ # any parse error we skip the field (column stays NULL) rather than failing.
599
+ if args.thread_media is not None:
600
+ try:
601
+ parsed_media = json.loads(args.thread_media)
602
+ if isinstance(parsed_media, list):
603
+ body["thread_media"] = parsed_media
604
+ except (TypeError, ValueError) as e:
605
+ print(json.dumps({
606
+ "warning": "THREAD_MEDIA_PARSE_FAILED",
607
+ "message": f"could not parse --thread-media: {e}",
608
+ }), file=sys.stderr)
609
+ # autoposter_version: stamped on every write so we can attribute
610
+ # engagement back to the release of the autoposter code that produced
611
+ # this row. None when package.json + env are both missing.
612
+ autoposter_version = read_autoposter_version()
613
+ if autoposter_version:
614
+ body["autoposter_version"] = autoposter_version
615
+ # Generation trace: read the JSON file and pass as-is. We do NOT
616
+ # validate the inner shape here; the API enforces the 64 KB cap and
617
+ # rejects non-object payloads. If the file is missing or unparseable
618
+ # we skip the field silently rather than failing the post — losing
619
+ # the audit row for one post is preferable to losing the post itself.
620
+ if args.generation_trace:
621
+ try:
622
+ with open(args.generation_trace, "r", encoding="utf-8") as tf:
623
+ body["generation_trace"] = json.load(tf)
624
+ except (OSError, json.JSONDecodeError) as e:
625
+ print(json.dumps({
626
+ "warning": "GENERATION_TRACE_LOAD_FAILED",
627
+ "message": f"could not load {args.generation_trace}: {e}",
628
+ }), file=sys.stderr)
629
+
630
+ resp = http_api.api_post("/api/v1/posts", body, ok_on_conflict=True)
631
+ if resp and _api_error_code(resp) in ("duplicate_thread", "conflict"):
632
+ print(json.dumps({
633
+ "error": "DUPLICATE_THREAD",
634
+ "message": "Already posted in this thread",
635
+ "existing_post_id": _api_error_detail(resp, "existing_post_id"),
636
+ "content_preview": _api_error_detail(resp, "content_preview"),
637
+ }))
638
+ return
639
+ # API response shape is {"ok":true,"data":{"post":{"id":N,...}}}.
640
+ # Earlier code looked at resp["post"]["id"] which silently returns None
641
+ # against the current API, causing twitter_post_plan.py to drop into the
642
+ # log_post_no_id branch even when the row was successfully inserted.
643
+ # Accept both shapes for backwards compat.
644
+ data = (resp or {}).get("data") or {}
645
+ post_obj = data.get("post") or (resp or {}).get("post") or {}
646
+ post_id = post_obj.get("id")
647
+ print(json.dumps({"logged": True, "post_id": post_id, "urns": urn_ids}))
648
+
649
+
650
+ if __name__ == "__main__":
651
+ main()