@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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Per-day PostHog funnel metrics for the dashboard stats tab.
|
|
3
|
+
|
|
4
|
+
Emits JSON on stdout:
|
|
5
|
+
{ "days": N,
|
|
6
|
+
"rows": [ {"day": "YYYY-MM-DD",
|
|
7
|
+
"pageviews": int,
|
|
8
|
+
"email_signups": int,
|
|
9
|
+
"schedule_clicks": int,
|
|
10
|
+
"get_started_clicks": int,
|
|
11
|
+
"cross_product_clicks": int,
|
|
12
|
+
"cta_clicks": int}, ... ] }
|
|
13
|
+
|
|
14
|
+
Aggregates across every project's domains listed in config.json, bucketed
|
|
15
|
+
by (POSTHOG_API_KEY, PROJECT_ID) so projects sharing a PostHog bucket
|
|
16
|
+
collapse into one HogQL call per metric.
|
|
17
|
+
|
|
18
|
+
Called by bin/server.js `/api/funnel/per-day`. Mirrors the auth/bucket
|
|
19
|
+
pattern of `project_stats_json.py`; cannot import it because that
|
|
20
|
+
module runs heavyweight project-stats work at import time.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
28
|
+
from datetime import datetime, timedelta, timezone
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
|
|
32
|
+
import project_stats as ps
|
|
33
|
+
from project_stats_json import _hogql, _SAFE_DOMAIN_RE, HogqlError, _GET_STARTED_EVENTS
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_EVENT_CLAUSES = {
|
|
37
|
+
"pageviews": "event = '$pageview'",
|
|
38
|
+
# Sessions: count distinct $session_id on $pageview events. PostHog sets
|
|
39
|
+
# $session_id on every autocapture/pageview, so this gives "web sessions"
|
|
40
|
+
# in the standard sense (one session per visitor per ~30min of activity).
|
|
41
|
+
# Used as the denominator for conversion-rate ratios on the Trends tab,
|
|
42
|
+
# since signups/sessions is the meaningful conversion metric (one user
|
|
43
|
+
# hitting 4 pages is one session, not four chances to convert).
|
|
44
|
+
"sessions": "event = '$pageview'",
|
|
45
|
+
# Email signups: client `newsletter_subscribed` is ad-blocker-lossy
|
|
46
|
+
# (~57% capture). Server-side `newsletter_subscribed_server` (added in
|
|
47
|
+
# @m13v/seo-components v0.38) fires from the API route after the Resend
|
|
48
|
+
# send succeeds, so it's ground truth. Both are counted with DISTINCT
|
|
49
|
+
# email so old client-only sites still show up while we transition;
|
|
50
|
+
# once both fire for the same submission they collapse into one row.
|
|
51
|
+
"email_signups": "event IN ('newsletter_subscribed', 'newsletter_subscribed_server')",
|
|
52
|
+
"schedule_clicks": "event = 'schedule_click'",
|
|
53
|
+
"get_started_clicks": f"event IN {_GET_STARTED_EVENTS}",
|
|
54
|
+
"cross_product_clicks": "event = 'cross_product_click'",
|
|
55
|
+
"cta_clicks": "event = 'cta_click'",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Metrics that need DISTINCT counting (e.g. dedupe client + server captures
|
|
59
|
+
# of the same event by email). Other metrics use plain count().
|
|
60
|
+
# `coalesce(properties.email, distinct_id)` because some emitters (studyly's
|
|
61
|
+
# /api/signup, custom routes) set only distinct_id=email and leave
|
|
62
|
+
# properties.email null; without coalesce those rows fall out of the count.
|
|
63
|
+
_DISTINCT_KEY = {
|
|
64
|
+
"email_signups": "coalesce(properties.email, distinct_id)",
|
|
65
|
+
# Everything else counts unique visitors (distinct_id), not raw events.
|
|
66
|
+
# Matches `project_stats_json.py` so the Trends tab and the Status tab
|
|
67
|
+
# tell the same story: a user iterating on mk0r with 4 prompts in a
|
|
68
|
+
# session is 1 get_started, not 4. The "sessions" row was previously
|
|
69
|
+
# DISTINCT $session_id; we collapse it to distinct_id so it now reads
|
|
70
|
+
# as "unique visitors per day" rather than "unique sessions per day",
|
|
71
|
+
# which is the metric we actually care about for conversion rates.
|
|
72
|
+
# Pageviews now also count unique visitors per day rather than raw
|
|
73
|
+
# pageview events, so the column header "Pageviews" is effectively
|
|
74
|
+
# "Unique visitors". Kept the key name as `pageviews` so the dashboard
|
|
75
|
+
# JS, server rollups, and historical persisted JSON files don't need
|
|
76
|
+
# a coordinated rename.
|
|
77
|
+
"pageviews": "distinct_id",
|
|
78
|
+
"sessions": "distinct_id",
|
|
79
|
+
"schedule_clicks": "distinct_id",
|
|
80
|
+
"get_started_clicks": "distinct_id",
|
|
81
|
+
"cross_product_clicks": "distinct_id",
|
|
82
|
+
"cta_clicks": "distinct_id",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _per_day_for_bucket(api_key, project_id, domains, days):
|
|
87
|
+
"""One HogQL query per metric, grouped by day, filtered to this bucket's domains."""
|
|
88
|
+
safe = [d for d in domains if _SAFE_DOMAIN_RE.match(d or "")]
|
|
89
|
+
if not safe or not days:
|
|
90
|
+
return {m: {} for m in _EVENT_CLAUSES}
|
|
91
|
+
in_list = ", ".join(f"'{d}'" for d in safe)
|
|
92
|
+
since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
|
93
|
+
out = {}
|
|
94
|
+
for metric, clause in _EVENT_CLAUSES.items():
|
|
95
|
+
distinct_key = _DISTINCT_KEY.get(metric)
|
|
96
|
+
count_expr = (
|
|
97
|
+
f"count(DISTINCT {distinct_key}) AS c"
|
|
98
|
+
if distinct_key
|
|
99
|
+
else "count() AS c"
|
|
100
|
+
)
|
|
101
|
+
q = (
|
|
102
|
+
f"SELECT toDate(timestamp) AS day, {count_expr} FROM events "
|
|
103
|
+
f"WHERE {clause} "
|
|
104
|
+
f"AND properties.$host IN ({in_list}) "
|
|
105
|
+
f"AND timestamp >= toDateTime('{since_iso}') "
|
|
106
|
+
"GROUP BY day ORDER BY day"
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
rows = _hogql(api_key, project_id, q)
|
|
110
|
+
except HogqlError as e:
|
|
111
|
+
print(f" HogQL error ({metric}, pid={project_id}): {e}", file=sys.stderr)
|
|
112
|
+
rows = []
|
|
113
|
+
out[metric] = {str(r[0]): int(r[1]) for r in (rows or []) if r and r[0] is not None}
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main():
|
|
118
|
+
parser = argparse.ArgumentParser()
|
|
119
|
+
parser.add_argument("--days", type=int, default=30)
|
|
120
|
+
parser.add_argument("--project", help="Filter to a single project name")
|
|
121
|
+
args = parser.parse_args()
|
|
122
|
+
days = max(1, min(365, args.days))
|
|
123
|
+
|
|
124
|
+
ps.load_env()
|
|
125
|
+
env = os.environ
|
|
126
|
+
config = ps.load_config()
|
|
127
|
+
|
|
128
|
+
default_key = env.get("POSTHOG_PERSONAL_API_KEY")
|
|
129
|
+
default_pid = env.get("POSTHOG_PROJECT_ID", "330744")
|
|
130
|
+
|
|
131
|
+
if not default_key:
|
|
132
|
+
print(json.dumps({"error": "POSTHOG_PERSONAL_API_KEY not set", "days": days, "rows": []}))
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
buckets = {} # (api_key, project_id) -> set(domains)
|
|
136
|
+
for proj in config.get("projects", []):
|
|
137
|
+
name = proj.get("name") or ""
|
|
138
|
+
if args.project and args.project.lower() != name.lower():
|
|
139
|
+
continue
|
|
140
|
+
domains = ps.get_project_domains(proj) or []
|
|
141
|
+
if not domains:
|
|
142
|
+
continue
|
|
143
|
+
over = proj.get("posthog", {}) or {}
|
|
144
|
+
key = env.get(over.get("api_key_env", ""), default_key)
|
|
145
|
+
pid = over.get("project_id", default_pid)
|
|
146
|
+
bucket = buckets.setdefault((key, pid), set())
|
|
147
|
+
for d in domains:
|
|
148
|
+
bucket.add(d)
|
|
149
|
+
|
|
150
|
+
if not buckets:
|
|
151
|
+
print(json.dumps({"days": days, "rows": []}))
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# One thread per bucket; each bucket issues len(_EVENT_CLAUSES) HogQL
|
|
155
|
+
# queries sequentially to stay inside PostHog's rate limit.
|
|
156
|
+
pool_size = max(2, min(8, len(buckets)))
|
|
157
|
+
metric_totals = {m: {} for m in _EVENT_CLAUSES} # metric -> {day: count}
|
|
158
|
+
error_msg = None
|
|
159
|
+
with ThreadPoolExecutor(max_workers=pool_size) as ex:
|
|
160
|
+
futs = {
|
|
161
|
+
ex.submit(_per_day_for_bucket, k, pid, sorted(ds), days): (k, pid)
|
|
162
|
+
for (k, pid), ds in buckets.items()
|
|
163
|
+
}
|
|
164
|
+
for fut in futs:
|
|
165
|
+
try:
|
|
166
|
+
bucket_metrics = fut.result()
|
|
167
|
+
except Exception as e:
|
|
168
|
+
error_msg = error_msg or f"PostHog batch error: {e}"
|
|
169
|
+
continue
|
|
170
|
+
for metric, day_counts in bucket_metrics.items():
|
|
171
|
+
agg = metric_totals[metric]
|
|
172
|
+
for day, c in day_counts.items():
|
|
173
|
+
agg[day] = agg.get(day, 0) + c
|
|
174
|
+
|
|
175
|
+
# Emit one row per day in the window (even zero-count days), sorted ascending.
|
|
176
|
+
today = datetime.now(timezone.utc).date()
|
|
177
|
+
start = today - timedelta(days=days - 1)
|
|
178
|
+
rows = []
|
|
179
|
+
for i in range(days):
|
|
180
|
+
d = start + timedelta(days=i)
|
|
181
|
+
key = d.strftime("%Y-%m-%d")
|
|
182
|
+
row = {"day": key}
|
|
183
|
+
for m in _EVENT_CLAUSES:
|
|
184
|
+
row[m] = int(metric_totals[m].get(key, 0))
|
|
185
|
+
rows.append(row)
|
|
186
|
+
|
|
187
|
+
out = {"days": days, "rows": rows}
|
|
188
|
+
if error_msg:
|
|
189
|
+
out["error"] = error_msg
|
|
190
|
+
print(json.dumps(out))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
main()
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Daily synthesizer: per platform, distill ONE engagement style from the
|
|
3
|
+
top human replies captured in the last 24h on that platform.
|
|
4
|
+
|
|
5
|
+
Pipeline (per platform)
|
|
6
|
+
-----------------------
|
|
7
|
+
1. Pull every `thread_top_replies` row from the last 24h on the platform
|
|
8
|
+
with `has_link = false` (human replies, not link-tail spam).
|
|
9
|
+
2. Score by likes (the only reliable proxy in the window, since `views` is
|
|
10
|
+
missing for most rows). Take the top REPLY_POOL_SIZE.
|
|
11
|
+
3. Build a Claude prompt that lists each reply with its like-count + thread
|
|
12
|
+
URL and asks the model to synthesize ONE new engagement style following
|
|
13
|
+
the seed-style schema (name / description / example / best_in / note).
|
|
14
|
+
4. Parse the JSON, POST to /api/v1/engagement-styles/registry with
|
|
15
|
+
kind="human_derived" and platform="<platform>" so it lands in the
|
|
16
|
+
single source-of-truth table alongside seeds and model-invented styles.
|
|
17
|
+
|
|
18
|
+
The picker (scripts/engagement_styles.py) reads the most recent active
|
|
19
|
+
row of kind='human_derived' for the calling platform via the same route
|
|
20
|
+
with HUMAN_DERIVED_RATE_BY_PLATFORM[platform] probability per call. See
|
|
21
|
+
migrations/2026-05-22_consolidate_engagement_styles_human_derived.sql for
|
|
22
|
+
the table shape and the table-consolidation rationale.
|
|
23
|
+
|
|
24
|
+
Run manually: python3 scripts/generate_daily_human_style.py
|
|
25
|
+
python3 scripts/generate_daily_human_style.py --platform twitter
|
|
26
|
+
python3 scripts/generate_daily_human_style.py --dry-run
|
|
27
|
+
|
|
28
|
+
Cron entry : skill/run-generate-daily-style.sh (wraps this via run_claude.sh).
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
from datetime import datetime, timedelta, timezone
|
|
37
|
+
|
|
38
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
39
|
+
sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
|
|
40
|
+
|
|
41
|
+
from http_api import api_get, api_post # noqa: E402
|
|
42
|
+
|
|
43
|
+
# Platforms we attempt synthesis for. Each platform that has >= MIN_REPLIES
|
|
44
|
+
# human replies in the window gets its own row in engagement_styles_registry
|
|
45
|
+
# (kind='human_derived'). Platforms with fewer rows are skipped silently —
|
|
46
|
+
# next run will try again.
|
|
47
|
+
PLATFORMS = ["twitter", "reddit", "github", "moltbook", "linkedin"]
|
|
48
|
+
|
|
49
|
+
REPLY_POOL_SIZE = 10 # top N human replies fed to Claude
|
|
50
|
+
WINDOW_HOURS = 24
|
|
51
|
+
MIN_LIKES = 5 # exclude noise-floor replies
|
|
52
|
+
MIN_REPLIES = 3 # skip platform if <3 qualifying rows
|
|
53
|
+
CLAUDE_MODEL_DEFAULT = None # inherit from settings.json
|
|
54
|
+
RUN_CLAUDE_PATH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
|
|
55
|
+
SCRIPT_TAG = "daily-human-style"
|
|
56
|
+
|
|
57
|
+
# target_chars computed from the live top-human-reply median (the whole point
|
|
58
|
+
# of the human_derived lane: learn the length that actually wins TODAY, not a
|
|
59
|
+
# static seed). Clamped to a sane reply-sized band so an outlier essay-reply or
|
|
60
|
+
# a one-word "this." can't drag the target out of range. IG long-form captions
|
|
61
|
+
# are NOT synthesized here (this lane is reply/comment-shaped), so the ceiling
|
|
62
|
+
# stays tweet-sized.
|
|
63
|
+
TARGET_CHARS_FLOOR = 30
|
|
64
|
+
TARGET_CHARS_CEIL = 300
|
|
65
|
+
DEFAULT_TARGET_CHARS = 80 # used only if median can't be computed
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def median_reply_chars(replies):
|
|
69
|
+
"""Median char length of the top human replies' content, clamped to the
|
|
70
|
+
reply-sized band. This is the realized length of what actually won the
|
|
71
|
+
thread today, so it becomes the style's target_chars: we aim to land where
|
|
72
|
+
humans land, not where our prompts historically bloated to (~215)."""
|
|
73
|
+
lengths = sorted(
|
|
74
|
+
len((r.get("reply_content") or "").strip())
|
|
75
|
+
for r in replies
|
|
76
|
+
if (r.get("reply_content") or "").strip()
|
|
77
|
+
)
|
|
78
|
+
if not lengths:
|
|
79
|
+
return DEFAULT_TARGET_CHARS
|
|
80
|
+
n = len(lengths)
|
|
81
|
+
mid = n // 2
|
|
82
|
+
med = lengths[mid] if n % 2 else (lengths[mid - 1] + lengths[mid]) // 2
|
|
83
|
+
return max(TARGET_CHARS_FLOOR, min(TARGET_CHARS_CEIL, int(med)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fetch_top_human_replies(platform,
|
|
87
|
+
limit=REPLY_POOL_SIZE, hours=WINDOW_HOURS):
|
|
88
|
+
"""Top human replies on `platform` from the last <hours>, ordered by likes.
|
|
89
|
+
|
|
90
|
+
`likes` is the only engagement column populated across all platforms
|
|
91
|
+
in thread_top_replies (views/comments/retweets are platform-shaped),
|
|
92
|
+
so we lean on it as the ranking key. has_link=false filters out
|
|
93
|
+
link-tail spam so the synthesizer only learns from organic moves.
|
|
94
|
+
|
|
95
|
+
Served via the HTTP API (thread-top-replies?top_human=1) so no DATABASE_URL
|
|
96
|
+
is needed. The route returns the same column shape this used to SELECT.
|
|
97
|
+
"""
|
|
98
|
+
resp = api_get(
|
|
99
|
+
"/api/v1/thread-top-replies",
|
|
100
|
+
query={
|
|
101
|
+
"top_human": "1",
|
|
102
|
+
"platform": platform,
|
|
103
|
+
"within_hours": int(hours),
|
|
104
|
+
"min_likes": MIN_LIKES,
|
|
105
|
+
"limit": int(limit),
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
return (resp.get("data") or {}).get("replies") or []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_existing_style_names():
|
|
112
|
+
"""Every active style name already in the registry (any kind), so the
|
|
113
|
+
model doesn't propose a collision. Falls back to the in-process STYLES
|
|
114
|
+
dict if the registry can't be reached.
|
|
115
|
+
|
|
116
|
+
Reads via the same route the picker uses, not via direct DB access.
|
|
117
|
+
"""
|
|
118
|
+
names = set()
|
|
119
|
+
try:
|
|
120
|
+
from engagement_styles import get_all_styles # noqa: WPS433
|
|
121
|
+
names.update(get_all_styles().keys())
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
# No DB fallback for the registry table — get_all_styles() already
|
|
125
|
+
# consults the route, and the in-memory STYLES dict is the cold-start
|
|
126
|
+
# floor it merges in. We don't want to bypass the route for an extra
|
|
127
|
+
# DB read here.
|
|
128
|
+
return names
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def already_generated_recently(platform, hours=20):
|
|
132
|
+
"""True if a human_derived style for `platform` was already created in the
|
|
133
|
+
last `hours`.
|
|
134
|
+
|
|
135
|
+
Idempotency guard. The daily cron fires ONCE at 16:00 PDT, so any second
|
|
136
|
+
human_derived row for the same platform inside ~20h is a duplicate, e.g. a
|
|
137
|
+
launchd catch-up run after the Mac woke from sleep, a manual rerun, or a
|
|
138
|
+
double-fire. Without this guard nothing stopped repeated invocations from
|
|
139
|
+
each minting a fresh style: on 2026-05-28 the synthesizer was invoked 4x
|
|
140
|
+
and inserted 4 twitter styles (peer_imperative, utility_for_reader_link,
|
|
141
|
+
build_in_public_artifact, proof_of_claim_link). The contract is "invent no
|
|
142
|
+
more than we consume" => at most one human_derived style per platform per
|
|
143
|
+
day.
|
|
144
|
+
|
|
145
|
+
Reads the newest human_derived row for the platform via the registry route
|
|
146
|
+
(kind=human_derived&platform=X&latest=1) and compares generated_at to the
|
|
147
|
+
window locally, so no DATABASE_URL is needed.
|
|
148
|
+
|
|
149
|
+
Fails OPEN (returns False) on any error so a transient blip never silently
|
|
150
|
+
kills the daily run.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
resp = api_get(
|
|
154
|
+
"/api/v1/engagement-styles/registry",
|
|
155
|
+
query={
|
|
156
|
+
"kind": "human_derived",
|
|
157
|
+
"platform": platform,
|
|
158
|
+
"latest": "1",
|
|
159
|
+
"status": "all",
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
styles = (resp.get("data") or {}).get("styles") or []
|
|
163
|
+
if not styles:
|
|
164
|
+
return False
|
|
165
|
+
gen = styles[0].get("generated_at")
|
|
166
|
+
if not gen:
|
|
167
|
+
return False
|
|
168
|
+
s = str(gen)
|
|
169
|
+
if s.endswith("Z"):
|
|
170
|
+
s = s[:-1] + "+00:00"
|
|
171
|
+
dt = datetime.fromisoformat(s)
|
|
172
|
+
if dt.tzinfo is None:
|
|
173
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
174
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
175
|
+
return dt >= cutoff
|
|
176
|
+
except Exception as e:
|
|
177
|
+
sys.stderr.write(
|
|
178
|
+
f"[generate_daily_human_style] platform={platform} idempotency "
|
|
179
|
+
f"check failed ({e}); proceeding (fail-open)\n"
|
|
180
|
+
)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_prompt(platform, replies, reserved_names):
|
|
185
|
+
lines = []
|
|
186
|
+
lines.append(
|
|
187
|
+
f"You are analyzing the top-performing human {platform} replies from "
|
|
188
|
+
f"the last {WINDOW_HOURS} hours and distilling ONE new engagement "
|
|
189
|
+
"style we can use for our own replies on that platform."
|
|
190
|
+
)
|
|
191
|
+
lines.append("")
|
|
192
|
+
lines.append("## What you're looking for")
|
|
193
|
+
lines.append("")
|
|
194
|
+
lines.append(
|
|
195
|
+
"These replies all WON the thread (top of the conversation by likes). "
|
|
196
|
+
"Find the shared pattern that makes them work — the rhetorical move, "
|
|
197
|
+
"the structural shape, the relationship to the OP. Most winners "
|
|
198
|
+
"share ONE pattern; that pattern is your new engagement style."
|
|
199
|
+
)
|
|
200
|
+
lines.append("")
|
|
201
|
+
lines.append(
|
|
202
|
+
"Ignore: replies that win because of follower count, fame, or "
|
|
203
|
+
"non-repeatable luck. Focus on the structural move that we (a "
|
|
204
|
+
"small account) could imitate and have a chance of replicating."
|
|
205
|
+
)
|
|
206
|
+
lines.append("")
|
|
207
|
+
lines.append(
|
|
208
|
+
f"## Top {len(replies)} human {platform} replies (by likes)"
|
|
209
|
+
)
|
|
210
|
+
lines.append("")
|
|
211
|
+
for i, r in enumerate(replies, 1):
|
|
212
|
+
lines.append(
|
|
213
|
+
f"### #{i} (likes={r['likes']}, replies={r['replies_count']}, "
|
|
214
|
+
f"rt={r['retweets']})"
|
|
215
|
+
)
|
|
216
|
+
lines.append(f"Thread: {r['thread_url']}")
|
|
217
|
+
handle = r.get("reply_author_handle") or "(unknown)"
|
|
218
|
+
lines.append(f"Reply by @{handle}:")
|
|
219
|
+
lines.append(f"> {r['reply_content']}")
|
|
220
|
+
lines.append("")
|
|
221
|
+
|
|
222
|
+
lines.append("## Schema (match exactly)")
|
|
223
|
+
lines.append("")
|
|
224
|
+
lines.append(
|
|
225
|
+
"Return ONE JSON object describing a single new engagement style. "
|
|
226
|
+
"No prose around it, no markdown code fence. The object MUST have "
|
|
227
|
+
"every field below:"
|
|
228
|
+
)
|
|
229
|
+
lines.append("")
|
|
230
|
+
lines.append("```")
|
|
231
|
+
lines.append("{")
|
|
232
|
+
lines.append(' "name": "<snake_case_name>",')
|
|
233
|
+
lines.append(' "description": "<one to three sentences describing the style>",')
|
|
234
|
+
lines.append(' "example": "<one short OP + reply pair demonstrating the style>",')
|
|
235
|
+
lines.append(' "best_in": {')
|
|
236
|
+
lines.append(f' "{platform}": ["<short context label>", ...],')
|
|
237
|
+
# Encourage the model to fill cross-platform `best_in` opportunistically
|
|
238
|
+
# when the move generalizes; leave as [] if not.
|
|
239
|
+
for other in PLATFORMS:
|
|
240
|
+
if other != platform:
|
|
241
|
+
lines.append(f' "{other}": [],')
|
|
242
|
+
lines.append(" },")
|
|
243
|
+
lines.append(' "note": "<one to two sentences: when to use, when not to>"')
|
|
244
|
+
lines.append("}")
|
|
245
|
+
lines.append("```")
|
|
246
|
+
lines.append("")
|
|
247
|
+
lines.append("## Rules")
|
|
248
|
+
lines.append("")
|
|
249
|
+
lines.append(
|
|
250
|
+
"1. The name must be unique. Reserved (do NOT propose these): "
|
|
251
|
+
f"{sorted(reserved_names)}."
|
|
252
|
+
)
|
|
253
|
+
lines.append(
|
|
254
|
+
"2. The name should be 2 to 4 snake_case tokens, descriptive of the "
|
|
255
|
+
"MOVE (e.g. `mirror_and_extend`, `flip_to_alt`, not `good_reply`)."
|
|
256
|
+
)
|
|
257
|
+
lines.append(
|
|
258
|
+
"3. The description should make the style copyable: a future model "
|
|
259
|
+
"reading just that one sentence should know what to write."
|
|
260
|
+
)
|
|
261
|
+
lines.append(
|
|
262
|
+
"4. The example should be a realistic OP + reply pair, not lifted "
|
|
263
|
+
"verbatim from the inputs."
|
|
264
|
+
)
|
|
265
|
+
lines.append(
|
|
266
|
+
f"5. best_in.{platform} is required (at least one context label). "
|
|
267
|
+
"Other platforms can stay empty arrays if the style is "
|
|
268
|
+
f"{platform}-specific."
|
|
269
|
+
)
|
|
270
|
+
lines.append(
|
|
271
|
+
"6. NEVER propose a style about including a product, a URL, or a "
|
|
272
|
+
"mechanism. Our link-tail layer handles that downstream. The style "
|
|
273
|
+
"is about the text BEFORE the link."
|
|
274
|
+
)
|
|
275
|
+
return "\n".join(lines)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def call_claude(prompt):
|
|
279
|
+
cmd = [RUN_CLAUDE_PATH, SCRIPT_TAG, "-p", prompt, "--output-format", "json"]
|
|
280
|
+
if CLAUDE_MODEL_DEFAULT:
|
|
281
|
+
cmd.extend(["--model", CLAUDE_MODEL_DEFAULT])
|
|
282
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
283
|
+
if result.returncode != 0:
|
|
284
|
+
sys.stderr.write(
|
|
285
|
+
f"[generate_daily_human_style] claude rc={result.returncode}\n"
|
|
286
|
+
f"stderr: {result.stderr[:2000]}\n"
|
|
287
|
+
)
|
|
288
|
+
raise RuntimeError(f"claude wrapper failed: rc={result.returncode}")
|
|
289
|
+
envelope = json.loads(result.stdout)
|
|
290
|
+
return envelope.get("result", "") or ""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_JSON_OBJ_RE = re.compile(r"\{[\s\S]*\}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def extract_json(text):
|
|
297
|
+
"""Tolerant of code fences or stray prose around the JSON object."""
|
|
298
|
+
fence = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
|
|
299
|
+
if fence:
|
|
300
|
+
return json.loads(fence.group(1))
|
|
301
|
+
m = _JSON_OBJ_RE.search(text)
|
|
302
|
+
if not m:
|
|
303
|
+
raise ValueError("No JSON object found in model output")
|
|
304
|
+
return json.loads(m.group(0))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def validate_style(style, platform, reserved_names):
|
|
308
|
+
"""Sanity-check the model output before we POST."""
|
|
309
|
+
required = {"name", "description", "example", "best_in", "note"}
|
|
310
|
+
missing = required - set(style.keys())
|
|
311
|
+
if missing:
|
|
312
|
+
raise ValueError(f"missing fields: {sorted(missing)}")
|
|
313
|
+
|
|
314
|
+
name = style["name"]
|
|
315
|
+
if not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9_]{2,40}", name):
|
|
316
|
+
raise ValueError(f"bad name: {name!r}")
|
|
317
|
+
if name in reserved_names:
|
|
318
|
+
raise ValueError(f"name collision: {name!r}")
|
|
319
|
+
|
|
320
|
+
best_in = style["best_in"]
|
|
321
|
+
if not isinstance(best_in, dict):
|
|
322
|
+
raise ValueError("best_in must be object")
|
|
323
|
+
# We require the calling platform key to be a non-empty list. Other
|
|
324
|
+
# platforms may be missing or empty — the route accepts them as long
|
|
325
|
+
# as the JSON shape is sane.
|
|
326
|
+
pf = best_in.get(platform)
|
|
327
|
+
if not isinstance(pf, list) or not pf:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"best_in.{platform} must be a non-empty list (source platform)"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
for field in ("description", "example", "note"):
|
|
333
|
+
if not isinstance(style[field], str) or not style[field].strip():
|
|
334
|
+
raise ValueError(f"{field} must be non-empty string")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def post_style(style, platform, replies, prompt_chars):
|
|
338
|
+
"""POST the synthesized style to the registry route. The route writes
|
|
339
|
+
to engagement_styles_registry with kind='human_derived' and platform=
|
|
340
|
+
<platform> (the picker filters on those two for the latest row).
|
|
341
|
+
Returns the parsed response (with style + created keys).
|
|
342
|
+
"""
|
|
343
|
+
source_post_ids = [r["id"] for r in replies]
|
|
344
|
+
target_chars = median_reply_chars(replies)
|
|
345
|
+
window_end = datetime.now(timezone.utc)
|
|
346
|
+
window_start = window_end - timedelta(hours=WINDOW_HOURS)
|
|
347
|
+
gen_log = (
|
|
348
|
+
f"Synthesized {window_end.isoformat(timespec='seconds')} from "
|
|
349
|
+
f"top {len(replies)} human {platform} replies in last "
|
|
350
|
+
f"{WINDOW_HOURS}h. Prompt size: {prompt_chars} chars. "
|
|
351
|
+
f"Reply id range: {min(source_post_ids)}-{max(source_post_ids)}. "
|
|
352
|
+
f"target_chars={target_chars} (median of source-reply lengths)."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
payload = {
|
|
356
|
+
"name": style["name"],
|
|
357
|
+
"description": style["description"],
|
|
358
|
+
"example": style["example"],
|
|
359
|
+
"note": style["note"],
|
|
360
|
+
"best_in": style["best_in"],
|
|
361
|
+
"target_chars": target_chars,
|
|
362
|
+
"kind": "human_derived",
|
|
363
|
+
"platform": platform,
|
|
364
|
+
"first_post_platform": platform,
|
|
365
|
+
"invented_by_model": "daily-human-style-synthesizer",
|
|
366
|
+
"source_window_start": window_start.isoformat(timespec="seconds"),
|
|
367
|
+
"source_window_end": window_end.isoformat(timespec="seconds"),
|
|
368
|
+
"source_post_ids": source_post_ids,
|
|
369
|
+
"generation_log": gen_log,
|
|
370
|
+
"generated_at": window_end.isoformat(timespec="seconds"),
|
|
371
|
+
}
|
|
372
|
+
return api_post(
|
|
373
|
+
"/api/v1/engagement-styles/registry", payload, ok_on_conflict=True,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def synthesize_for_platform(platform, reserved, dry_run=False):
|
|
378
|
+
"""Run the synthesizer for ONE platform. Returns a result dict for
|
|
379
|
+
summary logging; raises only on genuinely fatal errors (e.g. Claude
|
|
380
|
+
wrapper crash). Insufficient-data is a soft skip.
|
|
381
|
+
"""
|
|
382
|
+
# Idempotency: at most ONE human_derived style per platform per day. Skip
|
|
383
|
+
# before spending a Claude call if today's style already exists (rerun,
|
|
384
|
+
# launchd catch-up, double-fire). dry_run bypasses so prompts stay
|
|
385
|
+
# inspectable.
|
|
386
|
+
if not dry_run and already_generated_recently(platform):
|
|
387
|
+
sys.stderr.write(
|
|
388
|
+
f"[generate_daily_human_style] platform={platform} already has a "
|
|
389
|
+
f"human_derived style from the last 20h; skipping (idempotent).\n"
|
|
390
|
+
)
|
|
391
|
+
return {"platform": platform, "status": "skipped_already_today"}
|
|
392
|
+
replies = fetch_top_human_replies(platform)
|
|
393
|
+
if len(replies) < MIN_REPLIES:
|
|
394
|
+
sys.stderr.write(
|
|
395
|
+
f"[generate_daily_human_style] platform={platform} only "
|
|
396
|
+
f"{len(replies)} replies in last {WINDOW_HOURS}h (need "
|
|
397
|
+
f">={MIN_REPLIES}). Skipping.\n"
|
|
398
|
+
)
|
|
399
|
+
return {
|
|
400
|
+
"platform": platform,
|
|
401
|
+
"status": "skipped_insufficient_data",
|
|
402
|
+
"source_count": len(replies),
|
|
403
|
+
}
|
|
404
|
+
prompt = build_prompt(platform, replies, reserved)
|
|
405
|
+
sys.stderr.write(
|
|
406
|
+
f"[generate_daily_human_style] platform={platform} prompt "
|
|
407
|
+
f"{len(prompt)} chars, {len(replies)} replies, "
|
|
408
|
+
f"reserved={len(reserved)} names\n"
|
|
409
|
+
)
|
|
410
|
+
if dry_run:
|
|
411
|
+
return {
|
|
412
|
+
"platform": platform,
|
|
413
|
+
"status": "dry_run",
|
|
414
|
+
"source_count": len(replies),
|
|
415
|
+
"prompt_chars": len(prompt),
|
|
416
|
+
"source_likes_top": replies[0]["likes"],
|
|
417
|
+
"source_likes_bottom": replies[-1]["likes"],
|
|
418
|
+
"target_chars": median_reply_chars(replies),
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
text = call_claude(prompt)
|
|
422
|
+
if not text.strip():
|
|
423
|
+
sys.stderr.write(
|
|
424
|
+
f"[generate_daily_human_style] platform={platform} empty claude "
|
|
425
|
+
"output\n"
|
|
426
|
+
)
|
|
427
|
+
return {"platform": platform, "status": "empty_claude_output"}
|
|
428
|
+
style = extract_json(text)
|
|
429
|
+
validate_style(style, platform, reserved)
|
|
430
|
+
resp = post_style(style, platform, replies, len(prompt))
|
|
431
|
+
data = (resp or {}).get("data") or {}
|
|
432
|
+
created = bool(data.get("created"))
|
|
433
|
+
inserted = data.get("style") or {}
|
|
434
|
+
# Add the name to the live reserved set so the NEXT platform in the
|
|
435
|
+
# same run can't propose the same name.
|
|
436
|
+
if inserted.get("name"):
|
|
437
|
+
reserved.add(inserted["name"])
|
|
438
|
+
return {
|
|
439
|
+
"platform": platform,
|
|
440
|
+
"status": "ok" if created else "duplicate",
|
|
441
|
+
"name": inserted.get("name") or style["name"],
|
|
442
|
+
"kind": inserted.get("kind", "human_derived"),
|
|
443
|
+
"source_count": len(replies),
|
|
444
|
+
"source_likes_top": replies[0]["likes"],
|
|
445
|
+
"source_likes_bottom": replies[-1]["likes"],
|
|
446
|
+
"target_chars": median_reply_chars(replies),
|
|
447
|
+
"created": created,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def main():
|
|
452
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
453
|
+
ap.add_argument(
|
|
454
|
+
"--platform",
|
|
455
|
+
action="append",
|
|
456
|
+
choices=PLATFORMS,
|
|
457
|
+
help="Limit to one or more platforms (repeatable). Default: all.",
|
|
458
|
+
)
|
|
459
|
+
ap.add_argument(
|
|
460
|
+
"--dry-run",
|
|
461
|
+
action="store_true",
|
|
462
|
+
help="Build prompts and report counts; don't call Claude or POST.",
|
|
463
|
+
)
|
|
464
|
+
args = ap.parse_args()
|
|
465
|
+
platforms = args.platform or PLATFORMS
|
|
466
|
+
|
|
467
|
+
summary = []
|
|
468
|
+
reserved = load_existing_style_names()
|
|
469
|
+
for platform in platforms:
|
|
470
|
+
try:
|
|
471
|
+
result = synthesize_for_platform(
|
|
472
|
+
platform, reserved, dry_run=args.dry_run,
|
|
473
|
+
)
|
|
474
|
+
except Exception as e:
|
|
475
|
+
sys.stderr.write(
|
|
476
|
+
f"[generate_daily_human_style] platform={platform} "
|
|
477
|
+
f"failed: {e}\n"
|
|
478
|
+
)
|
|
479
|
+
result = {
|
|
480
|
+
"platform": platform,
|
|
481
|
+
"status": "error",
|
|
482
|
+
"error": str(e),
|
|
483
|
+
}
|
|
484
|
+
summary.append(result)
|
|
485
|
+
|
|
486
|
+
print(json.dumps({"runs": summary}, indent=2, default=str))
|
|
487
|
+
# Exit non-zero if every platform errored — soft skips don't count.
|
|
488
|
+
if summary and all(r.get("status") == "error" for r in summary):
|
|
489
|
+
return 1
|
|
490
|
+
return 0
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
if __name__ == "__main__":
|
|
494
|
+
sys.exit(main())
|