@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,82 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ enrich_twitter_candidates.py
4
+
5
+ Reads raw tweet JSON from stdin (output of browser scrape),
6
+ enriches each tweet with follower count and view count via fxtwitter API,
7
+ then outputs enriched JSON to stdout for piping to score_twitter_candidates.py.
8
+
9
+ Usage:
10
+ cat /tmp/raw_tweets.json | python3 scripts/enrich_twitter_candidates.py | python3 scripts/score_twitter_candidates.py
11
+ """
12
+
13
+ import json
14
+ import re
15
+ import sys
16
+ import urllib.request
17
+ from concurrent.futures import ThreadPoolExecutor
18
+
19
+
20
+ def fetch_fxtwitter(handle, tweet_id):
21
+ """Fetch tweet data from fxtwitter API."""
22
+ url = f"https://api.fxtwitter.com/{handle}/status/{tweet_id}"
23
+ req = urllib.request.Request(url, headers={"User-Agent": "social-autoposter/1.0"})
24
+ try:
25
+ with urllib.request.urlopen(req, timeout=10) as resp:
26
+ return json.loads(resp.read())
27
+ except Exception as e:
28
+ print(f" fxtwitter error for {handle}/{tweet_id}: {e}", file=sys.stderr)
29
+ return None
30
+
31
+
32
+ def enrich_one(tweet):
33
+ url = tweet.get("tweetUrl", tweet.get("tweet_url", ""))
34
+ if not url:
35
+ return None
36
+
37
+ m = re.search(r"x\.com/([^/]+)/status/(\d+)", url)
38
+ if not m:
39
+ m = re.search(r"twitter\.com/([^/]+)/status/(\d+)", url)
40
+ if not m:
41
+ return tweet
42
+
43
+ handle = m.group(1)
44
+ tweet_id = m.group(2)
45
+
46
+ data = fetch_fxtwitter(handle, tweet_id)
47
+ if data and data.get("tweet"):
48
+ t = data["tweet"]
49
+ author = t.get("author", {})
50
+ tweet["author_followers"] = author.get("followers", 0)
51
+ tweet["views"] = t.get("views", 0)
52
+ tweet["likes"] = t.get("likes", tweet.get("likes", 0))
53
+ tweet["retweets"] = t.get("retweets", tweet.get("retweets", 0))
54
+ tweet["replies"] = t.get("replies", tweet.get("replies", 0))
55
+ tweet["bookmarks"] = t.get("bookmarks", tweet.get("bookmarks", 0))
56
+ tweet["handle"] = author.get("screen_name", handle)
57
+
58
+ tweet["tweet_url"] = url
59
+ tweet.setdefault("text", tweet.get("tweetText", tweet.get("tweet_text", "")))
60
+ tweet.setdefault("datetime", tweet.get("tweetPostedAt", ""))
61
+ tweet.setdefault("handle", handle)
62
+ return tweet
63
+
64
+
65
+ def enrich(tweets):
66
+ with ThreadPoolExecutor(max_workers=8) as ex:
67
+ results = list(ex.map(enrich_one, tweets))
68
+ return [t for t in results if t is not None]
69
+
70
+
71
+ def main():
72
+ raw = json.load(sys.stdin)
73
+ if not isinstance(raw, list):
74
+ raw = [raw]
75
+
76
+ result = enrich(raw)
77
+ json.dump(result, sys.stdout)
78
+ print(f"\nEnriched {len(result)} tweets", file=sys.stderr)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env python3
2
+ """Feedback digest: distill human card decisions into learned_preferences.
3
+
4
+ The scheduled half of the review-events feedback loop (see
5
+ scripts/learned_preferences.py for the full loop). Per run:
6
+
7
+ 1. GET /api/v1/review-events?counts=true — which (project, platform) pairs
8
+ have unprocessed events. The API scopes to this installation, so a
9
+ customer box only ever digests its own user's decisions.
10
+ 2. For each project that exists in the local config.json: fetch the
11
+ unprocessed events, build a conservative digest prompt (current block +
12
+ events + approval counter-evidence), run Claude headless via
13
+ run_claude.sh (script_tag feedback-digest, cost-tracked like every other
14
+ pipeline Claude call).
15
+ 3. Apply the returned mutation plan through
16
+ learned_preferences.apply_mutations() (whitelist, flock, backup, atomic).
17
+ 4. PATCH the events processed (processed_batch=digest-<ts>) so they are
18
+ never digested twice. Events are marked processed even when the plan is
19
+ "no changes" — a considered no-op is a completed digestion, not a retry.
20
+
21
+ Overall feedback (decision='feedback', project IS NULL; typed into the card's
22
+ 💬 composer or the menu bar's "Send feedback…" item) is fetched once per run,
23
+ folded into EVERY configured project's prompt as explicit standing guidance,
24
+ and marked processed only after all attempted project digests succeed.
25
+ Loved approvals (the card's 😄 button) arrive as loved=true on approved
26
+ events and are surfaced to the model as strong positive evidence.
27
+
28
+ Failure handling: a Claude failure or unparseable plan leaves the events
29
+ unprocessed for the next run. A run-level flock prevents concurrent digests.
30
+
31
+ Stderr markers (load-bearing, dashboard-parsed; do not reformat):
32
+ [feedback_digest] project=<name> platform=<p> events=<n> applied=<x> dropped=<y> marked=<m>
33
+
34
+ Usage:
35
+ python3 scripts/feedback_digest.py # digest all pending
36
+ python3 scripts/feedback_digest.py --project fazm # one project
37
+ python3 scripts/feedback_digest.py --dry-run # print plans, change nothing
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import datetime
43
+ import fcntl
44
+ import json
45
+ import os
46
+ import re
47
+ import subprocess
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
52
+
53
+ # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists and
54
+ # scheduled-task prompts still export SAPS_*; this process reads S4L_*.
55
+ import s4l_env # noqa: E402
56
+
57
+ s4l_env.mirror()
58
+
59
+ from http_api import api_get, api_patch, api_post # noqa: E402
60
+ import learned_preferences as lp # noqa: E402
61
+
62
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
63
+ RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
64
+ LOCK_PATH = os.path.expanduser("~/.social-autoposter-mcp/feedback-digest.lock")
65
+ MAX_EVENTS_PER_RUN = 200
66
+ CLAUDE_TIMEOUT_SEC = 180
67
+
68
+ DISALLOWED_TOOLS = (
69
+ "ScheduleWakeup,CronCreate,CronDelete,CronList,EnterPlanMode,EnterWorktree,"
70
+ "Bash,Edit,Write,Read,Grep,Glob,WebFetch,WebSearch,Agent,TodoWrite,"
71
+ "NotebookEdit,LSP,Monitor,PushNotification,RemoteTrigger,TaskOutput,"
72
+ "TaskStop,ListMcpResourcesTool,ReadMcpResourceTool"
73
+ )
74
+
75
+
76
+ def log(msg: str) -> None:
77
+ print(f"[feedback_digest] {msg}", file=sys.stderr, flush=True)
78
+
79
+
80
+ def _now_stamp() -> str:
81
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S")
82
+
83
+
84
+ def load_config():
85
+ try:
86
+ return json.loads(Path(lp.config_path()).read_text())
87
+ except Exception:
88
+ return {"projects": []}
89
+
90
+
91
+ def _event_line(e: dict) -> str:
92
+ """One compact evidence line per event for the prompt."""
93
+ parts = [f"[{e.get('decision')}{'+loved' if e.get('loved') else ''}]"]
94
+ if e.get("reject_category"):
95
+ parts.append(f"category={e['reject_category']}")
96
+ if e.get("thread_author"):
97
+ parts.append(f"author=@{e['thread_author']}")
98
+ # Candidate-join context (author_followers, search_topic, tweet_text come
99
+ # from the LEFT JOIN in GET /api/v1/review-events): without it the model
100
+ # sees a bare handle and can never characterize the author TYPE behind a
101
+ # wrong_author reject. Older server deploys just omit the keys.
102
+ if e.get("author_followers") is not None:
103
+ parts.append(f"author_followers={e['author_followers']}")
104
+ if e.get("search_topic"):
105
+ parts.append(f"found_via_topic={e['search_topic']}")
106
+ inter = e.get("interactions") or []
107
+ kinds = sorted({str(i.get("type")) for i in inter if isinstance(i, dict) and i.get("type")})
108
+ if kinds:
109
+ parts.append(f"user_checked={'+'.join(kinds)}")
110
+ if e.get("dwell_ms"):
111
+ parts.append(f"dwell={round(e['dwell_ms'] / 1000, 1)}s")
112
+ if e.get("edited"):
113
+ parts.append("edited_before_approving")
114
+ line = " ".join(parts)
115
+ note = (e.get("reject_note") or "").strip()
116
+ if note:
117
+ line += f"\n user note: {note[:300]}"
118
+ tweet = (e.get("tweet_text") or "").strip()
119
+ if tweet:
120
+ line += f"\n their post was: {tweet[:200]}"
121
+ draft = (e.get("draft_text") or "").strip()
122
+ if draft:
123
+ line += f"\n our draft was: {draft[:200]}"
124
+ url = (e.get("thread_url") or "").strip()
125
+ if url:
126
+ line += f"\n thread: {url}"
127
+ return line
128
+
129
+
130
+ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] | None = None) -> str:
131
+ block = lp.get_block(project)
132
+ overall_events = overall_events or []
133
+ rejected = [e for e in events if e.get("decision") == "rejected"]
134
+ approved = [e for e in events if e.get("decision") == "approved"]
135
+ loved = [e for e in approved if e.get("loved")]
136
+ voice_never = ((project.get("voice") or {}).get("never")) or []
137
+ guard_do_not = ((project.get("content_guardrails") or {}).get("do_not")) or []
138
+
139
+ ev_lines = "\n".join(
140
+ f"{i + 1}. {_event_line(e)}" for i, e in enumerate(events)
141
+ ) or "(none this digest)"
142
+ overall_block = ""
143
+ if overall_events:
144
+ notes = "\n".join(
145
+ f"{i + 1}. {(e.get('reject_note') or '').strip()[:500]}"
146
+ for i, e in enumerate(overall_events)
147
+ )
148
+ overall_block = (
149
+ f"\n\nOVERALL FEEDBACK from the user ({len(overall_events)} "
150
+ f"note{'s' if len(overall_events) != 1 else ''}, typed into the feedback box; "
151
+ "explicit standing guidance about the whole pipeline, NOT about any single thread):\n"
152
+ f"{notes}"
153
+ )
154
+
155
+ return f"""You maintain the learned_preferences block for the project "{project.get('name')}" in a social-posting pipeline. The block distills the user's own approve/reject decisions on draft cards into short standing preferences that steer future thread selection and drafting. It is SOFT guidance read by the drafting model, not a filter.
156
+
157
+ CURRENT learned_preferences:
158
+ {json.dumps({k: block[k] for k in ("audience_avoid", "audience_prefer", "thread_avoid", "draft_style_notes")}, indent=2)}
159
+
160
+ CURRENT voice.never: {json.dumps(voice_never)}
161
+ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
162
+
163
+ NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(approved)} approved, {len(loved)} of the approvals loved):
164
+ {ev_lines}{overall_block}
165
+
166
+ Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user pressed the emphatic-approve button ("this was a really good one"): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
167
+
168
+ You can also block SPECIFIC authors via the plan's block_authors list. A block is a permanent hard exclusion of that one handle from all future thread selection, so it is YOUR judgment call, never automatic. Block when the evidence is strong: a wrong_author reject IS a direct human statement about that author (especially with profile_click), and the author context (author_followers, their post, found_via_topic) or the user's note confirms the account itself was the problem rather than the topic. Do NOT block when the reject looks topic-driven (off_topic/bad_draft on a reasonable account) or when you are unsure; the generalizable TYPE entry in audience_avoid is the softer tool for that.
169
+
170
+ Propose changes to the block. RULES, in priority order:
171
+ 1. Be conservative. Prefer NO changes over speculative ones. An empty plan is a good plan when the evidence is thin.
172
+ 2. Generalize only what the evidence supports: 2+ events agreeing justify a general entry; a single reject justifies at most one narrowly-scoped entry, and only when its note, interactions, or author context (follower count, their post, discovery topic) makes the reason explicit. Exceptions: a single loved approval can justify one audience_prefer/thread entry when the pattern it shows is clear, and OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one in the most fitting list even from a single line, rewritten as a standing preference.
173
+ 3. Describe author/audience TYPES, never individual handles. "crypto/web3-native accounts shilling tokens" is right; "@someguy" is wrong. Preferences must generalize.
174
+ 4. Approvals are counter-evidence. If approvals contradict an existing entry, propose removing or narrowing it. Also propose removing entries that events show are stale.
175
+ 5. bad_draft events feed draft_style_notes (or, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
176
+ 6. Each entry: one sentence, under 200 characters, plain language, no em dashes, no hashtags, understandable a month from now without these events.
177
+ 7. Respect the cap: at most {lp.MAX_ENTRIES_PER_LIST} entries per list. If a list is full, fold the new signal into an existing entry via remove+add.
178
+
179
+ OUTPUT: a single JSON object, nothing else. Schema:
180
+ {{"changes": {{"audience_avoid": {{"add": [], "remove": []}}, "audience_prefer": {{"add": [], "remove": []}}, "thread_avoid": {{"add": [], "remove": []}}, "draft_style_notes": {{"add": [], "remove": []}}}}, "voice_never_add": [], "guardrails_do_not_add": [], "block_authors": [{{"handle": "somehandle", "reason": "one short sentence citing the evidence"}}], "rationale": "one short sentence"}}
181
+ "remove" values must match existing entries EXACTLY. Omit empty keys if you like; an all-empty plan means "no changes"."""
182
+
183
+
184
+ def _provider_env() -> dict:
185
+ """Route the Claude turn through the local job queue (drained by the
186
+ saps-worker Claude Desktop scheduled task) whenever that worker is actually
187
+ firing; otherwise leave the provider unset so run_claude.sh execs the
188
+ claude CLI directly (operator Macs). An explicit S4L_CLAUDE_PROVIDER in
189
+ the environment always wins. This is the same queue lane the drafting
190
+ pipeline uses — the digest is just one more job type on it."""
191
+ env = dict(os.environ)
192
+ if env.get("S4L_CLAUDE_PROVIDER"):
193
+ return env
194
+ try:
195
+ import schedule_state
196
+
197
+ if schedule_state.compute() == "ok":
198
+ env["S4L_CLAUDE_PROVIDER"] = "queue"
199
+ except Exception:
200
+ pass
201
+ return env
202
+
203
+
204
+ def call_claude(prompt: str) -> tuple[bool, str, str]:
205
+ """Headless Claude turn, cost-tracked via run_claude.sh (script_tag
206
+ feedback-digest). Queue-routed when a worker is firing (see _provider_env);
207
+ otherwise mirrors scripts/link_tail.py call_claude()."""
208
+ env = _provider_env()
209
+ queued = env.get("S4L_CLAUDE_PROVIDER") == "queue"
210
+ # Queue lane waits for the every-minute worker to claim + draft; give it
211
+ # the same generous budget the pipeline's queued calls get.
212
+ timeout_sec = 900 if queued else CLAUDE_TIMEOUT_SEC
213
+ if os.path.exists(RUN_CLAUDE_SH):
214
+ cmd = ["bash", RUN_CLAUDE_SH, "feedback-digest", "-p", prompt,
215
+ "--max-turns", "1", "--disallowed-tools", DISALLOWED_TOOLS]
216
+ else:
217
+ cmd = ["claude", "-p", prompt, "--max-turns", "1",
218
+ "--disallowed-tools", DISALLOWED_TOOLS]
219
+ empty_mcp = "/tmp/.feedback_digest_empty_mcp.json"
220
+ try:
221
+ if not os.path.exists(empty_mcp):
222
+ Path(empty_mcp).write_text('{"mcpServers": {}}')
223
+ cmd += ["--strict-mcp-config", "--mcp-config", empty_mcp]
224
+ except Exception:
225
+ pass
226
+ try:
227
+ r = subprocess.run(cmd, capture_output=True, text=True,
228
+ timeout=timeout_sec, cwd=REPO_DIR, env=env)
229
+ out = (r.stdout or "").strip()
230
+ if r.returncode != 0:
231
+ return False, out, f"rc={r.returncode}: {(r.stderr or '')[:300]}"
232
+ if not out:
233
+ return False, "", "empty_stdout"
234
+ return True, out, ""
235
+ except subprocess.TimeoutExpired:
236
+ return False, "", f"timeout_{timeout_sec}s"
237
+ except FileNotFoundError as e:
238
+ return False, "", f"claude_cli_missing: {e}"
239
+
240
+
241
+ def parse_plan(text: str):
242
+ """Extract the JSON plan from model output (tolerates code fences and
243
+ surrounding prose). Returns dict or None."""
244
+ t = text.strip()
245
+ t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t, flags=re.MULTILINE).strip()
246
+ try:
247
+ obj = json.loads(t)
248
+ return obj if isinstance(obj, dict) else None
249
+ except Exception:
250
+ pass
251
+ start = t.find("{")
252
+ end = t.rfind("}")
253
+ if start != -1 and end > start:
254
+ try:
255
+ obj = json.loads(t[start : end + 1])
256
+ return obj if isinstance(obj, dict) else None
257
+ except Exception:
258
+ return None
259
+ return None
260
+
261
+
262
+ def digest_project(project: dict, platform: str, dry_run: bool,
263
+ overall_events: list[dict] | None = None) -> bool:
264
+ """Digest one project's pending events (plus any overall-feedback notes,
265
+ which ride along in every project's prompt but are marked processed by
266
+ main(), not here). Returns True when the digest completed (or there was
267
+ nothing to do); False leaves the events unprocessed for the next run."""
268
+ name = project.get("name")
269
+ overall_events = overall_events or []
270
+ resp = api_get("/api/v1/review-events",
271
+ {"project": name, "platform": platform, "unprocessed": "true",
272
+ "limit": str(MAX_EVENTS_PER_RUN)})
273
+ events = ((resp or {}).get("data") or {}).get("events") or []
274
+ if not events and not overall_events:
275
+ return True
276
+ prompt = build_prompt(project, events, overall_events)
277
+ if dry_run:
278
+ log(f"project={name} platform={platform} events={len(events)} overall={len(overall_events)} DRY RUN prompt below")
279
+ print(prompt)
280
+ ok, out, err = call_claude(prompt)
281
+ if not ok:
282
+ log(f"project={name} platform={platform} events={len(events)} claude_failed={err} (events left unprocessed)")
283
+ return False
284
+ plan = parse_plan(out)
285
+ if plan is None:
286
+ log(f"project={name} platform={platform} events={len(events)} plan_unparseable (events left unprocessed): {out[:200]}")
287
+ return False
288
+ if dry_run:
289
+ print(json.dumps(plan, indent=2))
290
+ log(f"project={name} platform={platform} events={len(events)} DRY RUN (nothing applied/marked)")
291
+ return True
292
+
293
+ # block_authors is applied through the blocklist API, not through
294
+ # learned_preferences: pop it before apply_mutations sees the plan.
295
+ block_authors = plan.pop("block_authors", None) or []
296
+
297
+ event_ids = [int(e["id"]) for e in events if str(e.get("id", "")).isdigit() or isinstance(e.get("id"), int)]
298
+ result = lp.apply_mutations(name, plan, source_event_ids=event_ids)
299
+ if not result.get("ok"):
300
+ log(f"project={name} platform={platform} events={len(events)} apply_failed={result.get('error')} (events left unprocessed)")
301
+ return False
302
+
303
+ # Author blocks the digest agent decided on (its judgment call, never
304
+ # automatic; see the prompt). Applied via the blocklist API so the
305
+ # discovery and reply gates enforce them. Best-effort per handle: a
306
+ # failed POST is logged and skipped, never fails the digest.
307
+ blocked = []
308
+ for entry in block_authors[:10]: # runaway-plan cap
309
+ if not isinstance(entry, dict):
310
+ continue
311
+ handle = str(entry.get("handle") or "").strip().lstrip("@").lower()
312
+ if not handle:
313
+ continue
314
+ reason = str(entry.get("reason") or "").strip()[:500] or "feedback digest judgment call"
315
+ try:
316
+ api_post("/api/v1/blocklist", {
317
+ "platform": platform,
318
+ "handle": handle,
319
+ "classification": "manual_block",
320
+ "severity": "hard",
321
+ "reason": f"feedback digest: {reason}",
322
+ "added_by": "feedback_digest",
323
+ "project": name,
324
+ }, ok_on_conflict=True)
325
+ blocked.append(handle)
326
+ except Exception as e:
327
+ log(f"project={name} block_author_failed handle={handle}: {e}")
328
+ if blocked:
329
+ log(f"project={name} blocked_authors: {', '.join(blocked)}")
330
+
331
+ marked = 0
332
+ if event_ids:
333
+ try:
334
+ presp = api_patch("/api/v1/review-events",
335
+ {"ids": event_ids, "action": "mark_processed",
336
+ "processed_batch": f"digest-{_now_stamp()}"})
337
+ marked = ((presp or {}).get("data") or {}).get("updated") or 0
338
+ except Exception as e:
339
+ log(f"project={name} mark_processed_failed={e} (idempotent: next run re-digests, apply dedups)")
340
+ log(
341
+ f"project={name} platform={platform} events={len(events)} "
342
+ f"applied={len(result.get('applied') or [])} dropped={len(result.get('dropped') or [])} marked={marked}"
343
+ )
344
+ for change in result.get("applied") or []:
345
+ log(f" {change}")
346
+ return True
347
+
348
+
349
+ def main() -> int:
350
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
351
+ ap.add_argument("--project", help="digest only this project")
352
+ ap.add_argument("--dry-run", action="store_true", help="print prompt+plan, change nothing")
353
+ ap.add_argument("--min-events", type=int,
354
+ default=int(os.environ.get("S4L_FEEDBACK_MIN_EVENTS", "1")),
355
+ help="skip a project until it has this many unprocessed events")
356
+ args = ap.parse_args()
357
+
358
+ Path(LOCK_PATH).parent.mkdir(parents=True, exist_ok=True)
359
+ lock_f = open(LOCK_PATH, "w")
360
+ try:
361
+ fcntl.flock(lock_f, fcntl.LOCK_EX | fcntl.LOCK_NB)
362
+ except BlockingIOError:
363
+ log("another digest run holds the lock; exiting")
364
+ return 0
365
+
366
+ cfg = load_config()
367
+ by_name = {p.get("name"): p for p in (cfg.get("projects") or [])}
368
+
369
+ resp = api_get("/api/v1/review-events", {"counts": "true"})
370
+ counts = ((resp or {}).get("data") or {}).get("counts") or []
371
+
372
+ # Overall feedback (decision='feedback', project IS NULL; the card's 💬
373
+ # button / menu bar "Send feedback…"): fetched once, folded into EVERY
374
+ # configured project's prompt, and marked processed only after all
375
+ # attempted digests succeed (apply_mutations dedups any re-digest).
376
+ overall_events: list[dict] = []
377
+ try:
378
+ oresp = api_get("/api/v1/review-events",
379
+ {"unprocessed": "true", "limit": "100"})
380
+ overall_events = [
381
+ e for e in (((oresp or {}).get("data") or {}).get("events") or [])
382
+ if e.get("decision") == "feedback" and not e.get("project")
383
+ ]
384
+ except Exception as e:
385
+ log(f"overall_feedback_fetch_error={e}")
386
+ if overall_events:
387
+ log(f"overall feedback notes pending: {len(overall_events)}")
388
+
389
+ if not counts and not overall_events:
390
+ log("no unprocessed review events")
391
+ return 0
392
+
393
+ todo: dict[tuple[str, str], int] = {}
394
+ for row in counts:
395
+ name = row.get("project")
396
+ if not name:
397
+ continue # project-less rows are the overall feedback handled above
398
+ platform = row.get("platform") or "twitter"
399
+ n = int(row.get("unprocessed") or 0)
400
+ if args.project and name != args.project:
401
+ continue
402
+ # Explicit overall feedback shouldn't wait on the card-event threshold.
403
+ if n < args.min_events and not overall_events:
404
+ log(f"project={name} platform={platform} events={n} below_min={args.min_events}, waiting")
405
+ continue
406
+ todo[(name, platform)] = n
407
+ if overall_events:
408
+ for name in by_name:
409
+ if args.project and name != args.project:
410
+ continue
411
+ todo.setdefault((name, "twitter"), 0)
412
+
413
+ attempted = 0
414
+ failures = 0
415
+ for (name, platform), _n in todo.items():
416
+ proj = by_name.get(name)
417
+ if proj is None:
418
+ log(f"project={name} not in local config, skipping (events left for the owning install)")
419
+ continue
420
+ attempted += 1
421
+ try:
422
+ if not digest_project(proj, platform, args.dry_run, overall_events):
423
+ failures += 1
424
+ except Exception as e:
425
+ failures += 1
426
+ log(f"project={name} digest_error={e}")
427
+
428
+ if overall_events and not args.dry_run:
429
+ if attempted and not failures:
430
+ ids = [int(e["id"]) for e in overall_events
431
+ if str(e.get("id", "")).isdigit() or isinstance(e.get("id"), int)]
432
+ try:
433
+ presp = api_patch("/api/v1/review-events",
434
+ {"ids": ids, "action": "mark_processed",
435
+ "processed_batch": f"digest-overall-{_now_stamp()}"})
436
+ marked = ((presp or {}).get("data") or {}).get("updated") or 0
437
+ log(f"overall feedback marked processed: {marked}")
438
+ except Exception as e:
439
+ log(f"overall mark_processed_failed={e} (re-digested next run; apply dedups)")
440
+ elif attempted:
441
+ log("overall feedback left unprocessed (a project digest failed; next run retries)")
442
+ else:
443
+ log("overall feedback pending but no configured project to digest into; left unprocessed")
444
+ return 0
445
+
446
+
447
+ if __name__ == "__main__":
448
+ sys.exit(main())