@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,350 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""counterparty_history.py — shared cross-pipeline counterparty memory.
|
|
3
|
+
|
|
4
|
+
Both the Reddit (engage_reddit.py) and Twitter (engage_twitter_helper.py)
|
|
5
|
+
public-engagement pipelines call get_counterparty_history_block(...) before
|
|
6
|
+
drafting a reply to a specific user. The block surfaces two lanes:
|
|
7
|
+
|
|
8
|
+
1. DM cross-thread history: from the dms table, scoped to OTHER posts
|
|
9
|
+
(different post_id). Reuses /api/v1/dms?their_author=X&exclude_post_id=N.
|
|
10
|
+
Also returns a same-post disengage signal (hard-skip in callers) when
|
|
11
|
+
the engage-dm-replies pipeline has already classified this person as
|
|
12
|
+
declined / not_our_prospect / stale on the CURRENT post.
|
|
13
|
+
|
|
14
|
+
2. Public-reply history: prior public comments WE made replying to this
|
|
15
|
+
author, via /api/v1/replies?their_author=X&status=replied. Lets the model
|
|
16
|
+
see whether it's repeating itself with this person, what tone has worked
|
|
17
|
+
before, what archetype has been used.
|
|
18
|
+
|
|
19
|
+
Returns (same_post_disengage, block_text). block_text is "" when no history
|
|
20
|
+
exists in either lane. Callers concatenate block_text into their prompt
|
|
21
|
+
(self-titled with its own H2 header, so no caller-side wrapping needed).
|
|
22
|
+
|
|
23
|
+
Why one shared helper: before 2026-05-19 the Reddit pipeline had its own
|
|
24
|
+
check_cross_pipeline_history() that pulled the DM lane only, while Twitter
|
|
25
|
+
had no counterparty memory at all. Splitting per-platform forks meant
|
|
26
|
+
either pipeline could drift on what gets surfaced (e.g. Reddit added the
|
|
27
|
+
public-reply lane while Twitter still flew blind). Single helper, both
|
|
28
|
+
callers, symmetric behavior.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
from datetime import datetime, timedelta, timezone
|
|
36
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
37
|
+
|
|
38
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
39
|
+
from http_api import api_get # noqa: E402
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fmt_date(s):
|
|
43
|
+
"""Format an ISO-ish timestamp string as YYYY-MM-DD, tolerant of None."""
|
|
44
|
+
if not s:
|
|
45
|
+
return "unknown"
|
|
46
|
+
try:
|
|
47
|
+
return str(s)[:10]
|
|
48
|
+
except Exception:
|
|
49
|
+
return "unknown"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _truncate(text, n=140):
|
|
53
|
+
if not text:
|
|
54
|
+
return ""
|
|
55
|
+
t = str(text).replace("\n", " ").strip()
|
|
56
|
+
return t if len(t) <= n else t[: n - 1] + "..."
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_TWITTER_STATUS_RE = re.compile(r"/status/(\d+)")
|
|
60
|
+
_REDDIT_COMMENT_RE = re.compile(r"/comments/([a-z0-9]+)/")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _conversation_root(platform, post_id, their_comment_url):
|
|
64
|
+
"""Best-effort key identifying the conversation root for grouping.
|
|
65
|
+
|
|
66
|
+
- Our own post: post_id (always wins when present and non-zero).
|
|
67
|
+
- Twitter guest thread: '/status/<id>' from the URL.
|
|
68
|
+
- Reddit guest thread: '/comments/<id>/' from the URL.
|
|
69
|
+
- Fallback: the URL with the last path segment stripped.
|
|
70
|
+
"""
|
|
71
|
+
if post_id:
|
|
72
|
+
return f"post:{post_id}"
|
|
73
|
+
if not their_comment_url:
|
|
74
|
+
return None
|
|
75
|
+
if platform == "x":
|
|
76
|
+
m = _TWITTER_STATUS_RE.search(their_comment_url)
|
|
77
|
+
if m:
|
|
78
|
+
return f"x_status:{m.group(1)}"
|
|
79
|
+
if platform == "reddit":
|
|
80
|
+
m = _REDDIT_COMMENT_RE.search(their_comment_url)
|
|
81
|
+
if m:
|
|
82
|
+
return f"r_thread:{m.group(1)}"
|
|
83
|
+
return f"url:{their_comment_url.rsplit('/', 1)[0]}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fetch_author_summary(platform, author, days=7):
|
|
87
|
+
"""Compute bot/loop-judgment stats for `author` in the last `days` window.
|
|
88
|
+
|
|
89
|
+
Returns a one-line summary string (or "" when there is no history).
|
|
90
|
+
Signals: total candidates, our replied count, our skipped count,
|
|
91
|
+
distinct conversation roots, our_replies / distinct_roots ratio (the
|
|
92
|
+
"engagement-loop shape" metric — closer to 1.0 = farm-shaped), skip
|
|
93
|
+
rate (% of our heuristics filtering this person out — low = bait too
|
|
94
|
+
clean), span_hours.
|
|
95
|
+
"""
|
|
96
|
+
if not author:
|
|
97
|
+
return ""
|
|
98
|
+
since_ts = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
|
|
99
|
+
try:
|
|
100
|
+
resp = api_get(
|
|
101
|
+
"/api/v1/replies",
|
|
102
|
+
query={
|
|
103
|
+
"platform": platform,
|
|
104
|
+
"their_author": author,
|
|
105
|
+
"since": since_ts,
|
|
106
|
+
"limit": 500,
|
|
107
|
+
"order_by": "discovered_at",
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
rows = ((resp or {}).get("data") or {}).get("replies") or []
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(
|
|
113
|
+
f"[counterparty_history] summary fetch failed for "
|
|
114
|
+
f"{platform}/@{author}: {e}",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
total = len(rows)
|
|
120
|
+
if total == 0:
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
replied = sum(1 for r in rows if r.get("status") == "replied")
|
|
124
|
+
skipped = sum(1 for r in rows if r.get("status") == "skipped")
|
|
125
|
+
roots = {_conversation_root(platform, r.get("post_id"), r.get("their_comment_url")) for r in rows}
|
|
126
|
+
roots.discard(None)
|
|
127
|
+
distinct_roots = len(roots) or 1
|
|
128
|
+
|
|
129
|
+
skip_pct = (skipped / total * 100.0) if total else 0.0
|
|
130
|
+
ratio = (replied / distinct_roots) if distinct_roots else 0.0
|
|
131
|
+
|
|
132
|
+
timestamps = []
|
|
133
|
+
for r in rows:
|
|
134
|
+
ts = r.get("discovered_at") or r.get("replied_at")
|
|
135
|
+
if not ts:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
timestamps.append(datetime.fromisoformat(str(ts).replace("Z", "+00:00")))
|
|
139
|
+
except Exception:
|
|
140
|
+
continue
|
|
141
|
+
span_h = 0.0
|
|
142
|
+
if len(timestamps) >= 2:
|
|
143
|
+
span_h = (max(timestamps) - min(timestamps)).total_seconds() / 3600.0
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
f"SUMMARY (last {days}d): {total} candidates, {replied} our_replies, "
|
|
147
|
+
f"{skipped} skipped ({skip_pct:.1f}% skip_rate), "
|
|
148
|
+
f"{distinct_roots} distinct conversation_roots "
|
|
149
|
+
f"(replies/root={ratio:.2f}, closer to 1.0 = farm-shaped), "
|
|
150
|
+
f"span={span_h:.1f}h"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _fetch_dm_history(platform, author, post_id):
|
|
155
|
+
"""Returns (same_post_disengage, other_thread_lines).
|
|
156
|
+
|
|
157
|
+
same_post_disengage is a dict {dm_id, interest_level, conversation_status, ...}
|
|
158
|
+
when this person has been classified declined/not_our_prospect/stale on
|
|
159
|
+
THIS post by the engage-dm-replies pipeline. Caller hard-skips.
|
|
160
|
+
|
|
161
|
+
other_thread_lines is a list of bullet strings for the soft-context
|
|
162
|
+
block (different post_id; tier, status, target_project, last message).
|
|
163
|
+
"""
|
|
164
|
+
same_post_disengage = None
|
|
165
|
+
other_lines = []
|
|
166
|
+
|
|
167
|
+
if post_id:
|
|
168
|
+
try:
|
|
169
|
+
same_resp = api_get(
|
|
170
|
+
"/api/v1/dms",
|
|
171
|
+
query={
|
|
172
|
+
"platform": platform,
|
|
173
|
+
"their_author": author,
|
|
174
|
+
"post_id": post_id,
|
|
175
|
+
"limit": 25,
|
|
176
|
+
"order_by": "last_message_at",
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
same_rows = ((same_resp or {}).get("data") or {}).get("dms") or []
|
|
180
|
+
for d in same_rows:
|
|
181
|
+
interest = d.get("interest_level")
|
|
182
|
+
convo_status = d.get("conversation_status")
|
|
183
|
+
if interest in ("declined", "not_our_prospect") or convo_status == "stale":
|
|
184
|
+
same_post_disengage = {
|
|
185
|
+
"dm_id": d.get("id"),
|
|
186
|
+
"interest_level": interest,
|
|
187
|
+
"conversation_status": convo_status,
|
|
188
|
+
"qualification_status": d.get("qualification_status"),
|
|
189
|
+
"last_message_at": d.get("last_message_at"),
|
|
190
|
+
}
|
|
191
|
+
break
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(
|
|
194
|
+
f"[counterparty_history] same-post dm check failed for "
|
|
195
|
+
f"{platform}/@{author} post={post_id}: {e}",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
query = {
|
|
201
|
+
"platform": platform,
|
|
202
|
+
"their_author": author,
|
|
203
|
+
"min_message_count": 1,
|
|
204
|
+
"with_last_message": "true",
|
|
205
|
+
"order_by": "last_message_at",
|
|
206
|
+
"limit": 5,
|
|
207
|
+
}
|
|
208
|
+
if post_id:
|
|
209
|
+
query["exclude_post_id"] = post_id
|
|
210
|
+
other_resp = api_get("/api/v1/dms", query=query)
|
|
211
|
+
other_rows = ((other_resp or {}).get("data") or {}).get("dms") or []
|
|
212
|
+
for r in other_rows:
|
|
213
|
+
ts = _fmt_date(r.get("last_message_at"))
|
|
214
|
+
interest = r.get("interest_level") or "unset"
|
|
215
|
+
mode = r.get("mode") or "unset"
|
|
216
|
+
status = r.get("conversation_status") or "unset"
|
|
217
|
+
tier = r.get("tier") if r.get("tier") is not None else "?"
|
|
218
|
+
msgs = r.get("message_count") or 0
|
|
219
|
+
target = r.get("target_project") or "-"
|
|
220
|
+
last = _truncate(r.get("last_msg"), 140)
|
|
221
|
+
other_lines.append(
|
|
222
|
+
f"- dm #{r.get('id')} on post #{r.get('post_id')} (last activity {ts}): "
|
|
223
|
+
f"interest={interest}, mode={mode}, status={status}, "
|
|
224
|
+
f"tier={tier}, messages={msgs}, target_project={target}\n"
|
|
225
|
+
f" last: {last}"
|
|
226
|
+
)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
print(
|
|
229
|
+
f"[counterparty_history] other-thread dm fetch failed for "
|
|
230
|
+
f"{platform}/@{author}: {e}",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return same_post_disengage, other_lines
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _fetch_public_reply_history(platform, author, current_reply_id=None, limit=5):
|
|
238
|
+
"""Returns a list of bullet strings describing our prior public replies
|
|
239
|
+
to this author (status=replied, our_reply_content non-empty).
|
|
240
|
+
|
|
241
|
+
Pulls limit+2 from the API so the client-side exclude_id filter (drop
|
|
242
|
+
the current reply we are about to draft) still yields `limit` lines.
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
resp = api_get(
|
|
246
|
+
"/api/v1/replies",
|
|
247
|
+
query={
|
|
248
|
+
"platform": platform,
|
|
249
|
+
"their_author": author,
|
|
250
|
+
"status": "replied",
|
|
251
|
+
"has_our_reply_content": "true",
|
|
252
|
+
"order_by": "replied_at",
|
|
253
|
+
"limit": int(limit) + 2,
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
rows = ((resp or {}).get("data") or {}).get("replies") or []
|
|
257
|
+
except Exception as e:
|
|
258
|
+
print(
|
|
259
|
+
f"[counterparty_history] public-reply fetch failed for "
|
|
260
|
+
f"{platform}/@{author}: {e}",
|
|
261
|
+
file=sys.stderr,
|
|
262
|
+
)
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
lines = []
|
|
266
|
+
for r in rows:
|
|
267
|
+
if current_reply_id and r.get("id") == current_reply_id:
|
|
268
|
+
continue
|
|
269
|
+
ts = _fmt_date(r.get("replied_at"))
|
|
270
|
+
style = r.get("engagement_style") or "?"
|
|
271
|
+
upv = r.get("upvotes") if r.get("upvotes") is not None else "?"
|
|
272
|
+
cmts = r.get("comments_count") if r.get("comments_count") is not None else "?"
|
|
273
|
+
post_id = r.get("post_id")
|
|
274
|
+
their_snippet = _truncate(r.get("their_content"), 120)
|
|
275
|
+
our_snippet = _truncate(r.get("our_reply_content"), 200)
|
|
276
|
+
lines.append(
|
|
277
|
+
f"- {ts} on post #{post_id} (style={style}, engagement: {upv} upvotes / {cmts} replies)\n"
|
|
278
|
+
f" they said: {their_snippet}\n"
|
|
279
|
+
f" we said: {our_snippet}"
|
|
280
|
+
)
|
|
281
|
+
if len(lines) >= limit:
|
|
282
|
+
break
|
|
283
|
+
return lines
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_counterparty_history_block(platform, author, current_post_id=None, current_reply_id=None):
|
|
287
|
+
"""Build the shared 'Prior history with @author' block.
|
|
288
|
+
|
|
289
|
+
Returns (same_post_disengage, block_text). block_text is "" when there
|
|
290
|
+
is nothing to surface in either lane.
|
|
291
|
+
|
|
292
|
+
Parallelizes the two API lanes (DM check + public-reply fetch) so the
|
|
293
|
+
helper's wall-clock is ~max(dm_latency, replies_latency) rather than
|
|
294
|
+
the sum.
|
|
295
|
+
"""
|
|
296
|
+
if not author:
|
|
297
|
+
return None, ""
|
|
298
|
+
|
|
299
|
+
with ThreadPoolExecutor(max_workers=3) as ex:
|
|
300
|
+
dm_fut = ex.submit(_fetch_dm_history, platform, author, current_post_id)
|
|
301
|
+
pub_fut = ex.submit(_fetch_public_reply_history, platform, author, current_reply_id)
|
|
302
|
+
sum_fut = ex.submit(_fetch_author_summary, platform, author, 7)
|
|
303
|
+
same_post_disengage, dm_lines = dm_fut.result()
|
|
304
|
+
pub_lines = pub_fut.result()
|
|
305
|
+
summary_line = sum_fut.result()
|
|
306
|
+
|
|
307
|
+
if not dm_lines and not pub_lines and not summary_line:
|
|
308
|
+
return same_post_disengage, ""
|
|
309
|
+
|
|
310
|
+
parts = [f"## Prior history with @{author}"]
|
|
311
|
+
parts.append(
|
|
312
|
+
"Soft context from past interactions across DM and public-reply rails. "
|
|
313
|
+
"Use this to gauge tone, avoid repeating yourself, and notice if they "
|
|
314
|
+
"have already declined or warmed up to a topic. Does NOT auto-block; "
|
|
315
|
+
"you still decide reply or skip based on the current thread."
|
|
316
|
+
)
|
|
317
|
+
if summary_line:
|
|
318
|
+
parts.append("")
|
|
319
|
+
parts.append(summary_line)
|
|
320
|
+
if dm_lines:
|
|
321
|
+
parts.append("\n### DM threads on other posts")
|
|
322
|
+
parts.append("\n".join(dm_lines))
|
|
323
|
+
if pub_lines:
|
|
324
|
+
parts.append("\n### Our prior public replies to this person")
|
|
325
|
+
parts.append("\n".join(pub_lines))
|
|
326
|
+
|
|
327
|
+
return same_post_disengage, "\n".join(parts)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
# Manual smoke-test:
|
|
332
|
+
# python3 counterparty_history.py reddit Secret_Theme3192
|
|
333
|
+
# python3 counterparty_history.py x someuser
|
|
334
|
+
import argparse
|
|
335
|
+
|
|
336
|
+
ap = argparse.ArgumentParser()
|
|
337
|
+
ap.add_argument("platform")
|
|
338
|
+
ap.add_argument("author")
|
|
339
|
+
ap.add_argument("--post-id", type=int, default=None)
|
|
340
|
+
ap.add_argument("--reply-id", type=int, default=None)
|
|
341
|
+
args = ap.parse_args()
|
|
342
|
+
|
|
343
|
+
disengage, block = get_counterparty_history_block(
|
|
344
|
+
args.platform, args.author,
|
|
345
|
+
current_post_id=args.post_id,
|
|
346
|
+
current_reply_id=args.reply_id,
|
|
347
|
+
)
|
|
348
|
+
print(f"same_post_disengage: {disengage}")
|
|
349
|
+
print("---")
|
|
350
|
+
print(block or "(empty block — no history found)")
|
package/scripts/db.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared env loader for social-autoposter (clean, shipped).
|
|
3
|
+
|
|
4
|
+
The published package talks to the central store exclusively through the S4L
|
|
5
|
+
HTTP API (scripts/http_api.py). It ships NO direct database dependency: no
|
|
6
|
+
psycopg2, no DATABASE_URL requirement.
|
|
7
|
+
|
|
8
|
+
This module provides `load_env()` (the only DB-agnostic helper every pipeline
|
|
9
|
+
needs) and, for LOCAL operator installs only, re-exports the direct-Postgres
|
|
10
|
+
connection layer from `db_direct.py` when that file is present. `db_direct.py`
|
|
11
|
+
is excluded from the npm tarball, so on a clean install the direct-DB symbols
|
|
12
|
+
resolve to a hard-error stub instead of importing psycopg2.
|
|
13
|
+
|
|
14
|
+
.env is read from ~/social-autoposter/.env (pre-filled on install).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_env():
|
|
23
|
+
if os.path.exists(ENV_PATH):
|
|
24
|
+
with open(ENV_PATH) as f:
|
|
25
|
+
for line in f:
|
|
26
|
+
line = line.strip()
|
|
27
|
+
if line and not line.startswith('#') and '=' in line:
|
|
28
|
+
k, v = line.split('=', 1)
|
|
29
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Re-export the direct-Postgres layer when running on a local operator install
|
|
33
|
+
# (db_direct.py present). In the published package db_direct.py is absent, so
|
|
34
|
+
# these names resolve to a stub that fails loudly if anything tries to open a
|
|
35
|
+
# direct DB connection — by design, the shipped pipelines use the HTTP API.
|
|
36
|
+
try:
|
|
37
|
+
from db_direct import ( # noqa: F401
|
|
38
|
+
get_conn,
|
|
39
|
+
PGConn,
|
|
40
|
+
snapshot_post_views,
|
|
41
|
+
)
|
|
42
|
+
except ImportError:
|
|
43
|
+
def _no_direct_db(*_args, **_kwargs):
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"Direct database access is not available in this build. "
|
|
46
|
+
"The published social-autoposter package uses the S4L HTTP API "
|
|
47
|
+
"(scripts/http_api.py); set AUTOPOSTER_API_BASE in ~/social-autoposter/.env."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def get_conn(*_args, **_kwargs): # noqa: F811
|
|
51
|
+
return _no_direct_db()
|
|
52
|
+
|
|
53
|
+
def snapshot_post_views(*_args, **_kwargs): # noqa: F811
|
|
54
|
+
# No-op stub: stats snapshots are an operator-local concern.
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
PGConn = None
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Discover Claude Desktop profiles on this Mac, which one is running, and what's installed.
|
|
3
|
+
|
|
4
|
+
A "profile" is any user-data-dir Claude Desktop has ever run with:
|
|
5
|
+
- the default: ~/Library/Application Support/Claude
|
|
6
|
+
- named ones: ~/Library/Application Support/Claude-<label> (account rotator convention)
|
|
7
|
+
|
|
8
|
+
Running detection: parse `ps` for /Applications/Claude.app/Contents/MacOS/Claude
|
|
9
|
+
and read its --user-data-dir flag (absence of the flag = default profile).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
APP_SUPPORT = Path.home() / "Library" / "Application Support"
|
|
21
|
+
ROTATOR_LABELS = Path.home() / "claude-account-rotator" / "labels.json"
|
|
22
|
+
|
|
23
|
+
# Dirs that start with "Claude" but are not Claude Desktop profiles
|
|
24
|
+
NOT_PROFILES = {"Claude Extensions", "ClaudeMeter", "claude-code"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_profile_dir(p: Path) -> bool:
|
|
28
|
+
if not p.is_dir() or p.name in NOT_PROFILES:
|
|
29
|
+
return False
|
|
30
|
+
# An Electron user-data-dir has Local State and/or Preferences; a used
|
|
31
|
+
# Claude profile additionally has config.json.
|
|
32
|
+
return (p / "Local State").exists() or (p / "config.json").exists()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def rotator_accounts() -> dict:
|
|
36
|
+
try:
|
|
37
|
+
return json.loads(ROTATOR_LABELS.read_text())
|
|
38
|
+
except Exception:
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def running_instances() -> dict:
|
|
43
|
+
"""Return {resolved_profile_path: pid} for every live Claude main process."""
|
|
44
|
+
out = subprocess.run(
|
|
45
|
+
["ps", "ax", "-o", "pid=,command="], capture_output=True, text=True
|
|
46
|
+
).stdout
|
|
47
|
+
found = {}
|
|
48
|
+
for line in out.splitlines():
|
|
49
|
+
m = re.match(r"\s*(\d+)\s+(/Applications/Claude\.app/Contents/MacOS/Claude)(\s|$)", line)
|
|
50
|
+
if not m:
|
|
51
|
+
continue
|
|
52
|
+
pid = int(m.group(1))
|
|
53
|
+
# Value may contain spaces ("Application Support"); it runs to the
|
|
54
|
+
# next --flag or end of line.
|
|
55
|
+
dm = re.search(r"--user-data-dir=(.+?)(?=\s+--|$)", line)
|
|
56
|
+
profile = Path(dm.group(1)) if dm else APP_SUPPORT / "Claude"
|
|
57
|
+
found[str(profile)] = pid
|
|
58
|
+
return found
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def profile_info(p: Path, running: dict, accounts: dict) -> dict:
|
|
62
|
+
info = {
|
|
63
|
+
"profile": p.name,
|
|
64
|
+
"path": str(p),
|
|
65
|
+
"running_pid": running.get(str(p)),
|
|
66
|
+
}
|
|
67
|
+
# Account label: rotator convention Claude-<label>
|
|
68
|
+
label = p.name.removeprefix("Claude-") if p.name != "Claude" else "default"
|
|
69
|
+
acct = accounts.get(label, {})
|
|
70
|
+
info["account_label"] = label
|
|
71
|
+
info["account_email"] = acct.get("email")
|
|
72
|
+
info["account_status"] = acct.get("status")
|
|
73
|
+
|
|
74
|
+
cfg_path = p / "config.json"
|
|
75
|
+
if cfg_path.exists():
|
|
76
|
+
try:
|
|
77
|
+
cfg = json.loads(cfg_path.read_text())
|
|
78
|
+
# Logged-in org UUIDs leak through the dxt allowlist cache keys
|
|
79
|
+
orgs = sorted(
|
|
80
|
+
{k.split(":")[-1] for k in cfg if k.startswith("dxt:allowlistEnabled:")}
|
|
81
|
+
)
|
|
82
|
+
info["org_uuids"] = orgs
|
|
83
|
+
info["signed_in"] = "oauth:tokenCacheV2" in cfg or "oauth:tokenCache" in cfg
|
|
84
|
+
except Exception as e:
|
|
85
|
+
info["config_error"] = str(e)
|
|
86
|
+
|
|
87
|
+
ext_dir = p / "Claude Extensions"
|
|
88
|
+
info["extensions"] = sorted(d.name for d in ext_dir.iterdir() if d.is_dir()) if ext_dir.is_dir() else []
|
|
89
|
+
|
|
90
|
+
# Last activity: mtime of config.json (touched constantly while running)
|
|
91
|
+
try:
|
|
92
|
+
ts = cfg_path.stat().st_mtime if cfg_path.exists() else p.stat().st_mtime
|
|
93
|
+
info["last_active"] = datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
return info
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
profiles = sorted(
|
|
101
|
+
(p for p in APP_SUPPORT.glob("Claude*") if is_profile_dir(p)),
|
|
102
|
+
key=lambda p: p.name,
|
|
103
|
+
)
|
|
104
|
+
running = running_instances()
|
|
105
|
+
accounts = rotator_accounts()
|
|
106
|
+
result = {
|
|
107
|
+
"running_count": len(running),
|
|
108
|
+
"profiles": [profile_info(p, running, accounts) for p in profiles],
|
|
109
|
+
}
|
|
110
|
+
# Flag any running instance whose profile dir we failed to enumerate
|
|
111
|
+
known = {str(p) for p in profiles}
|
|
112
|
+
orphans = {path: pid for path, pid in running.items() if path not in known}
|
|
113
|
+
if orphans:
|
|
114
|
+
result["running_unknown_profiles"] = orphans
|
|
115
|
+
json.dump(result, sys.stdout, indent=2)
|
|
116
|
+
print()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
main()
|