@m13v/s4l 1.6.197-rc.7
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 +1314 -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 +497 -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,581 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# engage-linkedin.sh — LinkedIn engagement loop
|
|
3
|
+
# Phase A: Discover replies/mentions from LinkedIn notifications (Claude-driven MCP).
|
|
4
|
+
# Phase B: Respond to pending LinkedIn replies (Claude-driven, OAuth API for posting).
|
|
5
|
+
# Called by launchd every 3 hours.
|
|
6
|
+
#
|
|
7
|
+
# IMPORTANT: all LinkedIn browser work goes through the linkedin-harness MCP
|
|
8
|
+
# (bh_run, CDP-driven real Chrome on port 9556), driven by Claude (the LLM).
|
|
9
|
+
# Do NOT re-introduce Python Playwright scrapers, Voyager API calls
|
|
10
|
+
# (/voyager/api/*), comment-page scroll+expand loops, or programmatic re-login
|
|
11
|
+
# flows. See CLAUDE.md "LinkedIn: flagged patterns to avoid" for why.
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
# LinkedIn killswitch (2026-05-27): refuse to run if a prior fire detected
|
|
16
|
+
# session compromise (http_999, authwall, throttle, li_at cleared).
|
|
17
|
+
# State: ~/.claude/social-autoposter/linkedin.killswitch
|
|
18
|
+
# Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
|
|
19
|
+
if [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
|
|
20
|
+
echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
|
|
21
|
+
echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# 2026-05-01: lock policy changed from "hold the entire run" to "hold only
|
|
26
|
+
# while a Claude phase is actively driving the browser" — same pattern as
|
|
27
|
+
# run-linkedin.sh. The old policy held linkedin-browser for the whole 25-45min
|
|
28
|
+
# cycle, starving peer pipelines (run-linkedin, dm-replies-linkedin) and
|
|
29
|
+
# defeating launchd schedules. The browser is only used inside the two
|
|
30
|
+
# run_claude.sh invocations (Phase A discovery, Phase B reply). Everything
|
|
31
|
+
# between (DB cleanup, pending pull, top performers, styles) is pure DB/CPU.
|
|
32
|
+
source "$(dirname "$0")/lock.sh"
|
|
33
|
+
# Browser backend bootstrap (linkedin-harness). Sets MCP_CONFIG_FILE,
|
|
34
|
+
# BROWSER_INSTRUCTIONS, exports LINKEDIN_CDP_URL, and provides
|
|
35
|
+
# ensure_linkedin_browser_for_backend. Migrated off the deprecated
|
|
36
|
+
# linkedin-agent MCP on 2026-05-29 (mirrors the Twitter harness migration).
|
|
37
|
+
source "$(dirname "$0")/lib/linkedin-backend.sh"
|
|
38
|
+
|
|
39
|
+
# Load secrets
|
|
40
|
+
# shellcheck source=/dev/null
|
|
41
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
42
|
+
|
|
43
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
44
|
+
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
45
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
46
|
+
BATCH_SIZE=500
|
|
47
|
+
MCP_CONFIG="$MCP_CONFIG_FILE"
|
|
48
|
+
|
|
49
|
+
# DB-free since 2026-06-01: all reply state goes through the s4l.ai HTTP API
|
|
50
|
+
# (X-Installation auth). No DATABASE_URL needed; the helpers below call the API.
|
|
51
|
+
PY_BIN="$(command -v python3 || echo /usr/bin/python3)"
|
|
52
|
+
|
|
53
|
+
# li_reply_count <status> -> integer count of linkedin replies in that status.
|
|
54
|
+
# Backed by GET /api/v1/replies/counts (same aggregate reply_db.py status uses).
|
|
55
|
+
li_reply_count() {
|
|
56
|
+
"$PY_BIN" -c "
|
|
57
|
+
import sys; sys.path.insert(0, '$REPO_DIR/scripts')
|
|
58
|
+
from http_api import api_get
|
|
59
|
+
resp = api_get('/api/v1/replies/counts', {'platform': 'linkedin'})
|
|
60
|
+
counts = ((resp or {}).get('data') or {}).get('counts') or []
|
|
61
|
+
want = '$1'
|
|
62
|
+
print(next((int(r.get('count', 0)) for r in counts if r.get('status') == want), 0))
|
|
63
|
+
" 2>/dev/null || echo 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# li_reset_processing <older_than_hours> -> count of rows reset to pending.
|
|
67
|
+
# older_than_hours=0 means no time gate (reset every processing row). Backed by
|
|
68
|
+
# POST /api/v1/replies/reset-stuck.
|
|
69
|
+
li_reset_processing() {
|
|
70
|
+
"$PY_BIN" -c "
|
|
71
|
+
import sys; sys.path.insert(0, '$REPO_DIR/scripts')
|
|
72
|
+
from http_api import api_post
|
|
73
|
+
resp = api_post('/api/v1/replies/reset-stuck', {'platform': 'linkedin', 'older_than_hours': int('$1')})
|
|
74
|
+
print(((resp or {}).get('data') or {}).get('reset_count', 0))
|
|
75
|
+
" 2>/dev/null || echo 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
mkdir -p "$LOG_DIR"
|
|
79
|
+
LOG_FILE="$LOG_DIR/engage-linkedin-$(date +%Y-%m-%d_%H%M%S).log"
|
|
80
|
+
|
|
81
|
+
# Per-cycle batch id stamped onto every claude_sessions row spawned by this
|
|
82
|
+
# engagement run (via SA_CYCLE_ID env -> log_claude_session.py). 2026-05-10
|
|
83
|
+
# cycle_id rollout.
|
|
84
|
+
BATCH_ID="enli-$(date +%Y%m%d_%H%M%S)-$$"
|
|
85
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
86
|
+
|
|
87
|
+
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
88
|
+
|
|
89
|
+
RUN_START=$(date +%s)
|
|
90
|
+
log "=== LinkedIn Engagement Run: $(date) ==="
|
|
91
|
+
|
|
92
|
+
# Load exclusions from config
|
|
93
|
+
EXCLUDED_AUTHORS=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(', '.join(c.get('exclusions',{}).get('authors',[])))" 2>/dev/null || echo "")
|
|
94
|
+
EXCLUDED_LINKEDIN=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(', '.join(c.get('exclusions',{}).get('linkedin_profiles',[])))" 2>/dev/null || echo "")
|
|
95
|
+
|
|
96
|
+
# ═══════════════════════════════════════════════════════
|
|
97
|
+
# PHASE A: Discover new replies/mentions from LinkedIn notifications
|
|
98
|
+
# Claude-driven: LLM navigates linkedin-agent MCP to /notifications/, extracts
|
|
99
|
+
# actionable items from the notifications page DOM (NOT from Voyager API,
|
|
100
|
+
# NOT by opening each permalink).
|
|
101
|
+
# ═══════════════════════════════════════════════════════
|
|
102
|
+
log "Phase A: Scanning LinkedIn notifications (Claude-driven)..."
|
|
103
|
+
|
|
104
|
+
PHASE_A_PROMPT=$(mktemp)
|
|
105
|
+
cat > "$PHASE_A_PROMPT" <<PROMPT_EOF
|
|
106
|
+
You are the Social Autoposter LinkedIn discovery bot.
|
|
107
|
+
|
|
108
|
+
Read $SKILL_FILE for content rules (tone, anti-AI detection, no em dashes).
|
|
109
|
+
|
|
110
|
+
$BROWSER_INSTRUCTIONS
|
|
111
|
+
|
|
112
|
+
## Task: Discover new LinkedIn replies and mentions from the notifications page
|
|
113
|
+
|
|
114
|
+
CRITICAL - Browser agent rule: ONLY use the browser tool described in the BROWSER BACKEND block above (mcp__linkedin-harness__bh_run). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, or mcp__macos-use__* tools.
|
|
115
|
+
CRITICAL: If a browser tool call is blocked or times out, wait 30 seconds and retry. Repeat up to 3 times. If still blocked, stop.
|
|
116
|
+
CRITICAL: Do NOT open individual post permalinks to fetch comment text. Everything we need is on the notifications page. Opening per-comment permalinks is a flagged scraping pattern.
|
|
117
|
+
CRITICAL: Do NOT call any /voyager/api/ endpoint, do NOT fetch() from the linkedin.com session. Use only UI navigation (the navigate/snapshot/run-code equivalents in the BROWSER BACKEND block).
|
|
118
|
+
|
|
119
|
+
EXCLUSIONS - do NOT engage with these accounts:
|
|
120
|
+
- Excluded authors: $EXCLUDED_AUTHORS
|
|
121
|
+
- Excluded LinkedIn profiles: $EXCLUDED_LINKEDIN
|
|
122
|
+
- Skip comments by "Matthew Diakonov" or "m13v" (our own account).
|
|
123
|
+
|
|
124
|
+
### Step 1: Load existing reply comment IDs (dedup)
|
|
125
|
+
\`\`\`bash
|
|
126
|
+
python3 ~/social-autoposter/scripts/li_discovery.py comment-ids
|
|
127
|
+
\`\`\`
|
|
128
|
+
Skip any notification whose comment URN is already in this list.
|
|
129
|
+
|
|
130
|
+
### Step 2: Load author+post pairs we already engaged with
|
|
131
|
+
\`\`\`bash
|
|
132
|
+
python3 ~/social-autoposter/scripts/li_discovery.py engaged-pairs
|
|
133
|
+
\`\`\`
|
|
134
|
+
Skip any notification whose (author, post) pair is already here. One reply per author per thread.
|
|
135
|
+
|
|
136
|
+
### Step 3: Load our LinkedIn posts for matching
|
|
137
|
+
\`\`\`bash
|
|
138
|
+
python3 ~/social-autoposter/scripts/li_discovery.py posts
|
|
139
|
+
\`\`\`
|
|
140
|
+
|
|
141
|
+
### Step 4: Navigate to LinkedIn notifications and verify session
|
|
142
|
+
Navigate (per the BROWSER BACKEND block) to https://www.linkedin.com/notifications/
|
|
143
|
+
|
|
144
|
+
Take a snapshot/screenshot and verify the page is the notifications feed (not a login/checkpoint page). If you see login, captcha, or a verification challenge, STOP immediately and print: SESSION_INVALID — do not attempt to log in. Exit.
|
|
145
|
+
|
|
146
|
+
### Step 5: Load more notifications
|
|
147
|
+
Scroll the page down a few times to lazy-load notifications. If a "Show more results" button is visible, click it — up to 5 times total, with a pause of 2-3 seconds between clicks. Stop if the button disappears.
|
|
148
|
+
|
|
149
|
+
### Step 6: Extract actionable notifications from the notifications page DOM
|
|
150
|
+
Run this DOM-extraction JS via the BROWSER BACKEND block's run-code equivalent
|
|
151
|
+
(bh_run with js("""...""")). It is a single read of the already-loaded page — do
|
|
152
|
+
NOT navigate to any other URL. The js() helper runs the body in page context and
|
|
153
|
+
returns the result, so pass exactly this body (it ends with \`return\`):
|
|
154
|
+
|
|
155
|
+
\`\`\`javascript
|
|
156
|
+
const actionable = [];
|
|
157
|
+
const actionablePhrases = [
|
|
158
|
+
'replied to your comment',
|
|
159
|
+
'mentioned you in a comment',
|
|
160
|
+
'mentioned you in this',
|
|
161
|
+
'commented on your post',
|
|
162
|
+
'commented on your update',
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
for (const article of document.querySelectorAll('article')) {
|
|
166
|
+
const text = (article.innerText || '').toLowerCase();
|
|
167
|
+
const matched = actionablePhrases.find(p => text.includes(p));
|
|
168
|
+
if (!matched) continue;
|
|
169
|
+
|
|
170
|
+
const strong = article.querySelector('strong');
|
|
171
|
+
const author = strong ? strong.textContent.trim() : 'unknown';
|
|
172
|
+
|
|
173
|
+
const link = article.querySelector('a[href*="commentUrn"]') ||
|
|
174
|
+
article.querySelector('a[href*="replyUrn"]') ||
|
|
175
|
+
article.querySelector('a[href*="feed/update"]');
|
|
176
|
+
const href = link ? link.getAttribute('href') : null;
|
|
177
|
+
if (!href) continue;
|
|
178
|
+
|
|
179
|
+
// Extract activity ID and commentUrn from the href
|
|
180
|
+
const activityMatch = href.match(/urn:li:activity:(\d+)/);
|
|
181
|
+
const activityId = activityMatch ? activityMatch[1] : null;
|
|
182
|
+
const commentUrnMatch = href.match(/commentUrn=([^&]+)/);
|
|
183
|
+
const commentUrn = commentUrnMatch ? decodeURIComponent(commentUrnMatch[1]) : null;
|
|
184
|
+
|
|
185
|
+
// Best-effort snippet: text inside the article, minus the author header
|
|
186
|
+
const snippet = (article.innerText || '').replace(/\s+/g, ' ').trim();
|
|
187
|
+
|
|
188
|
+
actionable.push({
|
|
189
|
+
type: matched,
|
|
190
|
+
author,
|
|
191
|
+
href: href.startsWith('http') ? href : ('https://www.linkedin.com' + href),
|
|
192
|
+
activity_id: activityId,
|
|
193
|
+
comment_urn: commentUrn,
|
|
194
|
+
snippet,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return JSON.stringify(actionable);
|
|
198
|
+
\`\`\`
|
|
199
|
+
|
|
200
|
+
### Step 7: For each extracted notification, decide whether to insert
|
|
201
|
+
For each item:
|
|
202
|
+
- If comment_urn is null OR activity_id is null: skip (no_comment_urn)
|
|
203
|
+
- If comment_urn is in the Step 1 dedup list: skip (already_tracked)
|
|
204
|
+
- If author matches an excluded account or is our own: skip (excluded_author / own_account)
|
|
205
|
+
- Build author_post_key = author + '|||' + our_url-for-this-post. If this pair is in the Step 2 list: skip (author_already_engaged)
|
|
206
|
+
- Find matching post_id from Step 3 by activity_id in the our_url. If none: create one (use PROJECT_NAME matched from config.json projects[].topics against the post topic):
|
|
207
|
+
\`\`\`bash
|
|
208
|
+
python3 ~/social-autoposter/scripts/li_discovery.py create-post --activity-id "ACTIVITY_ID" --project "PROJECT_NAME" --author "AUTHOR"
|
|
209
|
+
\`\`\`
|
|
210
|
+
This prints the post id (reusing the existing row on duplicate). Use that id as POST_ID below.
|
|
211
|
+
|
|
212
|
+
Insert the reply:
|
|
213
|
+
\`\`\`bash
|
|
214
|
+
python3 ~/social-autoposter/scripts/li_discovery.py insert-reply --post-id POST_ID --comment-urn "COMMENT_URN" --author "AUTHOR" --content "SNIPPET" --href "HREF"
|
|
215
|
+
\`\`\`
|
|
216
|
+
Prints the new reply id, or "duplicate" / "gated:<reason>" (both are non-fatal, just move to the next item).
|
|
217
|
+
|
|
218
|
+
### Step 8: Summary
|
|
219
|
+
Print:
|
|
220
|
+
- N new replies discovered
|
|
221
|
+
- N already tracked
|
|
222
|
+
- N author already engaged on thread
|
|
223
|
+
- N excluded
|
|
224
|
+
- N own account
|
|
225
|
+
- N no comment URN
|
|
226
|
+
|
|
227
|
+
### Step 8b: Structured scan-summary marker line (REQUIRED)
|
|
228
|
+
After the human-readable summary above, print EXACTLY ONE line with this
|
|
229
|
+
format (no other text on the line, all values are integers):
|
|
230
|
+
|
|
231
|
+
LINKEDIN_SCAN_SUMMARY: scanned=<TOTAL_NOTIFICATIONS_INSPECTED> new=<NEW_INSERTED> already=<ALREADY_TRACKED> excluded=<EXCLUDED+OWN_ACCOUNT> unmatched=<NO_COMMENT_URN>
|
|
232
|
+
|
|
233
|
+
The wrapper shell script greps this line to surface scan-stage pills
|
|
234
|
+
(scanned / new / excluded) on the dashboard Result column. If a counter
|
|
235
|
+
doesn't apply this run, emit it as 0 anyway. The line MUST start with
|
|
236
|
+
"LINKEDIN_SCAN_SUMMARY:" at column 0 with that exact spelling and casing.
|
|
237
|
+
PROMPT_EOF
|
|
238
|
+
|
|
239
|
+
# Acquire linkedin-browser ONLY around the Phase A Claude run. lock.sh is
|
|
240
|
+
# FIFO-queued, so a peer pipeline (run-linkedin, dm-replies-linkedin) that's
|
|
241
|
+
# mid-run blocks here rather than skipping. run_claude.sh auto-exports
|
|
242
|
+
# SA_PIPELINE_LOCKED=1 + SA_PIPELINE_PLATFORM so the PreToolUse hook
|
|
243
|
+
# (~/.claude/hooks/linkedin-agent-lock.sh) skips the cross-session block check.
|
|
244
|
+
acquire_lock "linkedin-browser" 3600
|
|
245
|
+
ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|
|
246
|
+
|
|
247
|
+
gtimeout 1800 "$REPO_DIR/scripts/run_claude.sh" "engage-linkedin-phaseA" --strict-mcp-config --mcp-config "$MCP_CONFIG" --output-format stream-json --verbose -p "$(cat "$PHASE_A_PROMPT")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: Phase A claude exited with code $?"
|
|
248
|
+
|
|
249
|
+
release_lock "linkedin-browser"
|
|
250
|
+
# Defense-in-depth: explicitly clear the hook-layer lockfile so the next
|
|
251
|
+
# pipeline cycle's PreToolUse never sees a stale entry from us. The
|
|
252
|
+
# run_claude.sh exit trap already does this in the happy path; this
|
|
253
|
+
# repeat is harmless and covers SIGKILL of run_claude.sh.
|
|
254
|
+
rm -f "$HOME/.claude/linkedin-agent-lock.json"
|
|
255
|
+
rm -f "$PHASE_A_PROMPT"
|
|
256
|
+
|
|
257
|
+
# ═══════════════════════════════════════════════════════
|
|
258
|
+
# PHASE B: Respond to pending LinkedIn replies
|
|
259
|
+
# Claude-driven. Posts via OAuth API (api.linkedin.com/v2/socialActions) by
|
|
260
|
+
# default (documented, authorized integration). Falls back to linkedin-agent
|
|
261
|
+
# MCP browser click-through if API errors.
|
|
262
|
+
# ═══════════════════════════════════════════════════════
|
|
263
|
+
|
|
264
|
+
# Reset any 'processing' items older than 2 hours back to 'pending'
|
|
265
|
+
RESET_COUNT=$(li_reset_processing 2)
|
|
266
|
+
[ "$RESET_COUNT" -gt 0 ] && log "Phase B: Reset $RESET_COUNT stuck 'processing' LinkedIn items back to pending"
|
|
267
|
+
|
|
268
|
+
PENDING_COUNT=$(li_reply_count pending)
|
|
269
|
+
|
|
270
|
+
if [ "$PENDING_COUNT" -eq 0 ]; then
|
|
271
|
+
log "Phase B: No pending LinkedIn replies. Done!"
|
|
272
|
+
else
|
|
273
|
+
log "Phase B: $PENDING_COUNT pending LinkedIn replies to process"
|
|
274
|
+
|
|
275
|
+
# Pull the full pending batch via the next-pending endpoint (no DATABASE_URL).
|
|
276
|
+
# The route applies the same ordering the old json_agg query used (our own
|
|
277
|
+
# posts first, then oldest discovered_at) and returns {replies:[...]}. We
|
|
278
|
+
# extract the array so PENDING_DATA stays the same JSON shape the prompt
|
|
279
|
+
# consumed before the migration.
|
|
280
|
+
PENDING_DATA=$("$PY_BIN" -c "
|
|
281
|
+
import json, sys
|
|
282
|
+
sys.path.insert(0, '$REPO_DIR/scripts')
|
|
283
|
+
from http_api import api_get
|
|
284
|
+
resp = api_get('/api/v1/replies/next-pending', {'platform': 'linkedin', 'limit': int('$BATCH_SIZE')})
|
|
285
|
+
rows = (resp.get('data') or {}).get('replies') or []
|
|
286
|
+
print(json.dumps(rows))
|
|
287
|
+
" 2>/dev/null || echo "[]")
|
|
288
|
+
|
|
289
|
+
# Per-project voice map (so each reply can be drafted in the matched project's voice)
|
|
290
|
+
PROJECTS_VOICE_JSON=$(python3 -c "
|
|
291
|
+
import json
|
|
292
|
+
c = json.load(open('$REPO_DIR/config.json'))
|
|
293
|
+
print(json.dumps({p['name']: p.get('voice', {}) for p in c.get('projects', []) if p.get('voice')}, indent=2))
|
|
294
|
+
" 2>/dev/null || echo "{}")
|
|
295
|
+
|
|
296
|
+
# Engagement-style picker (2026-05-31 LinkedIn alignment to Twitter): pick
|
|
297
|
+
# ONE assigned style per reply iteration PROGRAMMATICALLY, mirroring
|
|
298
|
+
# engage-twitter.sh. The picked style flows two places: (1) --style filter
|
|
299
|
+
# for top_performers.py so the per-style exemplars match the assignment,
|
|
300
|
+
# (2) saps_render_style_block so the prompt embeds the same assignment. On
|
|
301
|
+
# invent mode picked_style is empty and top_performers stays unfiltered.
|
|
302
|
+
# Replaces the legacy generate_styles_block (which discarded the pick and
|
|
303
|
+
# let the model invent freely).
|
|
304
|
+
source "$REPO_DIR/skill/styles.sh"
|
|
305
|
+
STYLE_ASSIGN_FILE=$(mktemp -t saps_linkedin_eng_assign_XXXXXX.json)
|
|
306
|
+
saps_pick_style linkedin replying "$STYLE_ASSIGN_FILE" >/dev/null 2>&1 || true
|
|
307
|
+
PICKED_STYLE=$(python3 -c "
|
|
308
|
+
import json
|
|
309
|
+
try:
|
|
310
|
+
with open('$STYLE_ASSIGN_FILE') as f:
|
|
311
|
+
d = json.load(f)
|
|
312
|
+
print(d.get('style') or '')
|
|
313
|
+
except Exception:
|
|
314
|
+
print('')
|
|
315
|
+
" 2>/dev/null)
|
|
316
|
+
PICKED_MODE=$(python3 -c "
|
|
317
|
+
import json
|
|
318
|
+
try:
|
|
319
|
+
with open('$STYLE_ASSIGN_FILE') as f:
|
|
320
|
+
d = json.load(f)
|
|
321
|
+
print(d.get('mode') or 'use')
|
|
322
|
+
except Exception:
|
|
323
|
+
print('use')
|
|
324
|
+
" 2>/dev/null)
|
|
325
|
+
STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" linkedin replying)
|
|
326
|
+
rm -f "$STYLE_ASSIGN_FILE" 2>/dev/null || true
|
|
327
|
+
|
|
328
|
+
# Top performers feedback report — filtered to the picked style when in
|
|
329
|
+
# 'use' mode so the few-shot exemplars match the assignment.
|
|
330
|
+
if [ -n "$PICKED_STYLE" ]; then
|
|
331
|
+
TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform linkedin --style "$PICKED_STYLE" 2>/dev/null || echo "(top performers report unavailable)")
|
|
332
|
+
else
|
|
333
|
+
TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform linkedin 2>/dev/null || echo "(top performers report unavailable)")
|
|
334
|
+
fi
|
|
335
|
+
|
|
336
|
+
PHASE_B_PROMPT=$(mktemp)
|
|
337
|
+
cat > "$PHASE_B_PROMPT" <<PROMPT_EOF
|
|
338
|
+
You are the Social Autoposter LinkedIn engagement bot.
|
|
339
|
+
|
|
340
|
+
Read $SKILL_FILE for the full workflow, content rules, and platform details.
|
|
341
|
+
|
|
342
|
+
$BROWSER_INSTRUCTIONS
|
|
343
|
+
|
|
344
|
+
EXCLUSIONS - do NOT engage with these accounts (skip and mark as 'skipped' with reason 'excluded_author'):
|
|
345
|
+
- Excluded authors: $EXCLUDED_AUTHORS
|
|
346
|
+
- Excluded LinkedIn profiles: $EXCLUDED_LINKEDIN
|
|
347
|
+
|
|
348
|
+
### BOT / ENGAGEMENT-LOOP ESCAPE HATCH (use sparingly, but use it)
|
|
349
|
+
We maintain a universal author blocklist in Postgres (\`author_blocklist\`),
|
|
350
|
+
consulted at /api/v1/replies POST time. A single block recorded by ANY of
|
|
351
|
+
our accounts/installs applies to EVERY future engagement from EVERY of our
|
|
352
|
+
accounts — universal scope, by design. The velocity gate already covers
|
|
353
|
+
"this handle has gotten too many replies from us in 24h/7d"; this lane is
|
|
354
|
+
for the LLM-judgment cases velocity cannot catch.
|
|
355
|
+
|
|
356
|
+
When to add a block (your judgment, exercised CONSERVATIVELY):
|
|
357
|
+
- The profile is plainly an AI/bot account: templated phrasing, generic
|
|
358
|
+
filler answers, name/headline reads "AI growth agent" / "comments on
|
|
359
|
+
posts for you", bio is engagement-farming boilerplate
|
|
360
|
+
- We are clearly stuck in a reciprocal engagement loop with this profile
|
|
361
|
+
(they comment on every one of our posts, we reply to every one of theirs,
|
|
362
|
+
no substance is exchanged)
|
|
363
|
+
- The profile is engagement farming (mass low-effort comments across the
|
|
364
|
+
platform, not actually engaging with the topic)
|
|
365
|
+
|
|
366
|
+
DO NOT add a block for: someone we disagree with, a hostile-but-human
|
|
367
|
+
critic, a low-quality but human comment, or a single bad interaction.
|
|
368
|
+
Skip those (status='skipped') — blocking is permanent until manually
|
|
369
|
+
removed and applies to all our accounts.
|
|
370
|
+
|
|
371
|
+
How to add the block (run BEFORE marking the current reply skipped). The
|
|
372
|
+
handle to pass is the URL-vanity portion of /in/<vanity>/ (e.g. for
|
|
373
|
+
linkedin.com/in/jane-doe-123/, pass jane-doe-123):
|
|
374
|
+
python3 \$REPO_DIR/scripts/reply_db.py blocklist add linkedin HANDLE \\
|
|
375
|
+
--reason "<one-line judgment>" \\
|
|
376
|
+
--classification {bot|engagement_loop} \\
|
|
377
|
+
--source-reply-id REPLY_ID
|
|
378
|
+
|
|
379
|
+
Then mark the current reply skipped with a clear reason:
|
|
380
|
+
python3 \$REPO_DIR/scripts/reply_db.py skipped REPLY_ID "blocklist_added:HANDLE"
|
|
381
|
+
|
|
382
|
+
CRITICAL - Browser agent rule: ONLY use the browser tool described in the BROWSER BACKEND block above (mcp__linkedin-harness__bh_run). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, or mcp__macos-use__* tools.
|
|
383
|
+
CRITICAL: If a browser tool call is blocked or times out, DO NOT fall back to any other browser tool. Wait 30 seconds and retry. Repeat up to 3 times.
|
|
384
|
+
CRITICAL: TECHNICAL FAILURES ARE NOT TERMINAL. If after retries the action still failed for any technical reason (browser blocked, MCP timeout, page rendering issue, linkedin.com unreachable, linkedin_api.py 5xx), DO NOT call reply_db.py skipped. Leave the row in 'processing' status and move on to the next pending item. The next engage run's start-of-script cleanup resets stuck 'processing' rows back to 'pending' and retries automatically.
|
|
385
|
+
CRITICAL: ONLY call reply_db.py skipped for content/policy reasons (e.g., light_acknowledgment, drive_by_self_promo, hostile_user, off_topic, troll, excluded_author). NEVER skip for technical browser/network failures: those must be retry-able.
|
|
386
|
+
CRITICAL: Do NOT call /voyager/api/ endpoints. Posting goes through linkedin_api.py (OAuth api.linkedin.com). Browser is the fallback only.
|
|
387
|
+
|
|
388
|
+
## Respond to pending LinkedIn replies ($PENDING_COUNT total)
|
|
389
|
+
|
|
390
|
+
### Priority order:
|
|
391
|
+
1. **Replies on our original posts** (is_our_original_post=1) - highest priority
|
|
392
|
+
2. **Direct questions** ("what tool", "how do you", "can you share")
|
|
393
|
+
3. **Everything else** - general engagement
|
|
394
|
+
|
|
395
|
+
### Tiered link strategy:
|
|
396
|
+
- **Tier 1 (default):** No link. Genuine engagement, expand topic.
|
|
397
|
+
- **Tier 2 (natural mention):** Conversation touches a topic matching a project in config. Recommend it casually as a tool you've come across.
|
|
398
|
+
- **Tier 3 (direct ask):** They ask for link/tool/source. Give it immediately.
|
|
399
|
+
|
|
400
|
+
## FEEDBACK FROM PAST PERFORMANCE (use this to write better replies):
|
|
401
|
+
$TOP_REPORT
|
|
402
|
+
|
|
403
|
+
$STYLES_BLOCK
|
|
404
|
+
|
|
405
|
+
## Per-project voice map
|
|
406
|
+
For each reply you draft, look up the matched project's voice block below and apply it: follow \`voice.tone\`, never violate any item in \`voice.never\`, mirror \`voice.examples\` / \`voice.examples_good\` when present.
|
|
407
|
+
$PROJECTS_VOICE_JSON
|
|
408
|
+
|
|
409
|
+
## Resolving the parent post (replaces the old prompt-blob index)
|
|
410
|
+
Each pending row's \`project_name\` is a best-effort guess. After navigating the thread (Step 2), extract the activity_id from the page URL/comment URN and resolve it via:
|
|
411
|
+
python3 $REPO_DIR/scripts/lookup_post.py linkedin ACTIVITY_ID
|
|
412
|
+
Returns JSON: {"project": "fazm", "our_content": "...full text...", "thread_url": "..."} or {"project": null} if it's not one of our posts.
|
|
413
|
+
|
|
414
|
+
Here are the replies to process:
|
|
415
|
+
$PENDING_DATA
|
|
416
|
+
|
|
417
|
+
CRITICAL: Reply in the SAME LANGUAGE as the message you are responding to. Match the language exactly.
|
|
418
|
+
CRITICAL: Process EVERY reply. For each: either post a response and mark as 'replied', OR mark as 'skipped' with a skip_reason.
|
|
419
|
+
|
|
420
|
+
CRITICAL: For ALL database operations, use the reply_db.py helper (NOT raw psql):
|
|
421
|
+
python3 $REPO_DIR/scripts/reply_db.py processing ID # BEFORE posting
|
|
422
|
+
python3 $REPO_DIR/scripts/reply_db.py replied ID "reply text" [url] [engagement_style] [is_recommendation] # AFTER posting. engagement_style is TONE (critic, storyteller, etc). Pass "1" for is_recommendation ONLY when the reply casually recommends a project (Tier 2/3); leave blank otherwise.
|
|
423
|
+
python3 $REPO_DIR/scripts/reply_db.py skipped ID "reason"
|
|
424
|
+
python3 $REPO_DIR/scripts/reply_db.py skip_batch '{"ids":[1,2,3],"reason":"..."}'
|
|
425
|
+
python3 $REPO_DIR/scripts/reply_db.py status
|
|
426
|
+
NEVER use psql directly for reply status updates.
|
|
427
|
+
|
|
428
|
+
### Project tracking on replies
|
|
429
|
+
When you recommend a project in a reply (Tier 2 or Tier 3), set project_name on the reply:
|
|
430
|
+
python3 $REPO_DIR/scripts/reply_db.py set_project REPLY_ID "PROJECT_NAME"
|
|
431
|
+
|
|
432
|
+
MANDATORY reply flow for every item:
|
|
433
|
+
Step 1: python3 reply_db.py processing ID <- mark BEFORE posting
|
|
434
|
+
Step 2: NAVIGATE TO THE THREAD AND READ CONTEXT (mandatory, do NOT skip).
|
|
435
|
+
Do NOT draft a reply from the notification snippet alone — the snippet
|
|
436
|
+
is truncated and lacks the parent post content + sibling replies.
|
|
437
|
+
a) Navigate (per the BROWSER BACKEND block) to their_comment_url
|
|
438
|
+
b) Snapshot/screenshot (per the BROWSER BACKEND block) to read:
|
|
439
|
+
- the FULL parent post text (our original post if this is on our thread)
|
|
440
|
+
- the immediate ancestor of their_comment_id
|
|
441
|
+
- sibling replies (so you don't repeat what someone else already said)
|
|
442
|
+
c) Extract the activity_id (the long numeric string after \`urn:li:activity:\`)
|
|
443
|
+
from the URL or comment URN. Resolve it:
|
|
444
|
+
python3 $REPO_DIR/scripts/lookup_post.py linkedin ACTIVITY_ID
|
|
445
|
+
If the response has a non-null \"project\", that's our post, OVERRIDE
|
|
446
|
+
the reply row and use that project's voice for drafting:
|
|
447
|
+
python3 $REPO_DIR/scripts/reply_db.py set_project REPLY_ID "RESOLVED_PROJECT"
|
|
448
|
+
Use the returned \"our_content\" as the FULL text of the post being
|
|
449
|
+
replied to (more accurate than the truncated our_content in PENDING_DATA).
|
|
450
|
+
If \"project\" is null, we're a guest in someone else's thread; keep
|
|
451
|
+
the existing project_name and follow global content rules.
|
|
452
|
+
Step 3: Draft the reply using the resolved project's voice + the ASSIGNED
|
|
453
|
+
engagement style. This cycle: mode=$PICKED_MODE style='${PICKED_STYLE:-(invent)}'.
|
|
454
|
+
In USE mode ($PICKED_MODE=use) apply the assigned style '${PICKED_STYLE}'
|
|
455
|
+
verbatim; do NOT pick a different style and do NOT invent one. In INVENT
|
|
456
|
+
mode ($PICKED_MODE=invent) craft a NEW snake_case style name not in the
|
|
457
|
+
curated block above and pass it as the [engagement_style] arg in Step 5.
|
|
458
|
+
Professional but casual. NEVER em dashes. Match parent post language.
|
|
459
|
+
Step 4: post reply (OAuth API first, browser fallback)
|
|
460
|
+
Step 5: python3 reply_db.py replied ID "text" [url] [engagement_style] [is_recommendation] <- mark AFTER success. engagement_style is the style name you applied (in USE mode the assigned '${PICKED_STYLE}'). is_recommendation="1" only when you mentioned a project (Tier 2/3).
|
|
461
|
+
If Step 5 fails, the item stays 'processing' and will be reset to 'pending' on the next run.
|
|
462
|
+
|
|
463
|
+
For LinkedIn replies - use the OAuth API first:
|
|
464
|
+
1. Extract the activity ID from their_comment_url or their_comment_id.
|
|
465
|
+
- From their_comment_id like \`urn:li:comment:(activity:7438226125077549056,7438815640536170496)\`, the activity ID is \`7438226125077549056\` and the full URN is the parent_comment_urn.
|
|
466
|
+
- From their_comment_url, extract the activity ID from the URL path.
|
|
467
|
+
2. Post the reply via API:
|
|
468
|
+
\`\`\`bash
|
|
469
|
+
python3 $REPO_DIR/scripts/linkedin_api.py reply ACTIVITY_ID "PARENT_COMMENT_URN" "YOUR REPLY TEXT" --project "RESOLVED_PROJECT_NAME" --reply-id REPLY_ID
|
|
470
|
+
\`\`\`
|
|
471
|
+
Replace RESOLVED_PROJECT_NAME with the project from lookup_post.py (or the row's project_name if lookup returned null).
|
|
472
|
+
Replace REPLY_ID with the numeric id from the current reply row.
|
|
473
|
+
The API will automatically wrap any URLs in the reply text with short tracking links and backfill the reply_id after posting.
|
|
474
|
+
This returns JSON with {ok, reply_urn, permalink}. Use permalink as the reply URL.
|
|
475
|
+
On {"ok": true}: skip step 3 (browser fallback) and skip the browser-based
|
|
476
|
+
verification in step 5 below — the API success response (with reply_urn) is
|
|
477
|
+
itself authoritative. Mark replied with the permalink. Do NOT navigate the
|
|
478
|
+
browser to verify; that would burn the linkedin-browser lock for no gain.
|
|
479
|
+
3. If the API call fails (e.g., token expired, comment deleted), fall back to the linkedin-agent browser:
|
|
480
|
+
- UTM-WRAP YOUR_REPLY_TEXT FIRST. The browser-typing path has no Python wrap
|
|
481
|
+
layer, so a bare URL would be posted as-is and we'd lose attribution. Run:
|
|
482
|
+
python3 \$REPO_DIR/scripts/dm_short_links.py utm-text \\
|
|
483
|
+
--platform linkedin \\
|
|
484
|
+
--project RESOLVED_PROJECT_NAME \\
|
|
485
|
+
--text "YOUR_REPLY_TEXT"
|
|
486
|
+
Use the printed string going forward. No DB write happens. Safe to run
|
|
487
|
+
unconditionally (no-op when YOUR_REPLY_TEXT contains zero URLs).
|
|
488
|
+
- Navigate to their_comment_url (per the BROWSER BACKEND block)
|
|
489
|
+
- Snapshot/screenshot to find the comment, click Reply (click_at_xy on the Reply control), type the (UTM-wrapped) text, submit
|
|
490
|
+
- Do NOT aggressively scroll-and-expand comments; if the comment isn't visible after a normal scroll, mark as 'skipped' with reason 'comment_not_found'
|
|
491
|
+
4. If both API and browser fail, mark as 'skipped' with reason 'comment_not_found'.
|
|
492
|
+
|
|
493
|
+
5. POST-SUBMIT VERIFICATION (mandatory, BROWSER-FALLBACK PATH ONLY).
|
|
494
|
+
If you posted via the OAuth API in step 2 and got {"ok": true}, SKIP this
|
|
495
|
+
block entirely — the API response is the verification. Run this block ONLY
|
|
496
|
+
when step 3's browser fallback was used. Verify visually + via DOM (the
|
|
497
|
+
harness has no network-capture tool; do NOT try to read /voyager or socialActions
|
|
498
|
+
network traffic — that is a flagged pattern anyway):
|
|
499
|
+
5a. Capture a screenshot (bh_run print(capture_screenshot())) and Read the PNG to
|
|
500
|
+
check for a toast / submit feedback.
|
|
501
|
+
5b. Read the DOM via the BROWSER BACKEND block's run-code equivalent (bh_run with
|
|
502
|
+
js("""...""")) and check:
|
|
503
|
+
(a) a fresh comment by 'Matthew Diakonov' / 'You' is rendered under their_comment
|
|
504
|
+
(b) NO 'could not be created' / 'try again' / 'something went wrong' toast text
|
|
505
|
+
(c) reply editor textbox cleared (empty contenteditable)
|
|
506
|
+
5c. SUCCESS = (a) passes with no toast. REJECTED = error toast present OR our
|
|
507
|
+
reply not visible in the DOM.
|
|
508
|
+
6. If REJECTED, do NOT call reply_db.py replied. Mark soft-blocked:
|
|
509
|
+
python3 $REPO_DIR/scripts/reply_db.py skipped ID "soft_blocked: <verbatim toast or 'quiet_fail_count_unchanged'>"
|
|
510
|
+
Then STOP this row and move to the next pending reply.
|
|
511
|
+
7. If step 5 SUCCESS (or step 2 OAuth success), mark replied via Step 5 of the
|
|
512
|
+
MANDATORY reply flow above (reply_db.py replied ID "text" [url] [style] [is_recommendation]).
|
|
513
|
+
|
|
514
|
+
After every 10 replies, run: python3 $REPO_DIR/scripts/reply_db.py status
|
|
515
|
+
PROMPT_EOF
|
|
516
|
+
|
|
517
|
+
# Re-acquire linkedin-browser ONLY for Phase B. The lock was released after
|
|
518
|
+
# Phase A so peer pipelines could use the browser during our DB-pull /
|
|
519
|
+
# styles-prep window (~1-3s). FIFO ticket queue in lock.sh ensures
|
|
520
|
+
# fairness if a peer or parallel cycle grabbed it in the meantime.
|
|
521
|
+
acquire_lock "linkedin-browser" 3600
|
|
522
|
+
ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|
|
523
|
+
|
|
524
|
+
gtimeout 5400 "$REPO_DIR/scripts/run_claude.sh" "engage-linkedin-phaseB" --strict-mcp-config --mcp-config "$MCP_CONFIG" --output-format stream-json --verbose -p "$(cat "$PHASE_B_PROMPT")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: Phase B claude exited with code $?"
|
|
525
|
+
|
|
526
|
+
release_lock "linkedin-browser"
|
|
527
|
+
# Defense-in-depth: explicit hook-lockfile cleanup; see Phase A note.
|
|
528
|
+
rm -f "$HOME/.claude/linkedin-agent-lock.json"
|
|
529
|
+
rm -f "$PHASE_B_PROMPT"
|
|
530
|
+
fi
|
|
531
|
+
|
|
532
|
+
# Reset any items left in 'processing' after subprocess exit (tech-failure
|
|
533
|
+
# retry path: agent leaves rows here on browser/MCP failure rather than
|
|
534
|
+
# calling reply_db.py skipped, so the next run picks them up automatically).
|
|
535
|
+
# older_than_hours=0 resets ALL 'processing' rows regardless of age.
|
|
536
|
+
POST_RESET=$(li_reset_processing 0)
|
|
537
|
+
[ "$POST_RESET" -gt 0 ] && log "Post-run: Reset $POST_RESET 'processing' LinkedIn items back to pending"
|
|
538
|
+
|
|
539
|
+
# ═══════════════════════════════════════════════════════
|
|
540
|
+
# Cleanup
|
|
541
|
+
# ═══════════════════════════════════════════════════════
|
|
542
|
+
TOTAL_PENDING=$(li_reply_count pending)
|
|
543
|
+
TOTAL_REPLIED=$(li_reply_count replied)
|
|
544
|
+
TOTAL_SKIPPED=$(li_reply_count skipped)
|
|
545
|
+
|
|
546
|
+
log "LinkedIn summary: pending=$TOTAL_PENDING replied=$TOTAL_REPLIED skipped=$TOTAL_SKIPPED"
|
|
547
|
+
|
|
548
|
+
# Log run to persistent monitor
|
|
549
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
550
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "engage-linkedin-phaseA" "engage-linkedin-phaseB" 2>/dev/null || echo "0.0000")
|
|
551
|
+
# Pull Phase A scan-stage counters from the structured marker line Claude emits
|
|
552
|
+
# at the end of Phase A: "LINKEDIN_SCAN_SUMMARY: scanned=N new=N already=N
|
|
553
|
+
# excluded=N unmatched=N". If the marker is missing (Claude failed before
|
|
554
|
+
# printing or the prompt drifted), fall back to no scan= segment and the
|
|
555
|
+
# dashboard renders the old way.
|
|
556
|
+
LI_SCAN_LINE=$(grep -m1 "^LINKEDIN_SCAN_SUMMARY:" "$LOG_FILE" 2>/dev/null || true)
|
|
557
|
+
LI_SCAN_ARG=""
|
|
558
|
+
if [ -n "$LI_SCAN_LINE" ]; then
|
|
559
|
+
li_scanned=$(echo "$LI_SCAN_LINE" | grep -oE "scanned=[0-9]+" | head -1 | cut -d= -f2)
|
|
560
|
+
li_new=$(echo "$LI_SCAN_LINE" | grep -oE "new=[0-9]+" | head -1 | cut -d= -f2)
|
|
561
|
+
li_already=$(echo "$LI_SCAN_LINE" | grep -oE "already=[0-9]+" | head -1 | cut -d= -f2)
|
|
562
|
+
li_excl=$(echo "$LI_SCAN_LINE" | grep -oE "excluded=[0-9]+" | head -1 | cut -d= -f2)
|
|
563
|
+
li_unm=$(echo "$LI_SCAN_LINE" | grep -oE "unmatched=[0-9]+" | head -1 | cut -d= -f2)
|
|
564
|
+
parts=""
|
|
565
|
+
[ -n "$li_scanned" ] && parts="${parts}scanned=${li_scanned},"
|
|
566
|
+
[ -n "$li_new" ] && parts="${parts}new=${li_new},"
|
|
567
|
+
[ -n "$li_already" ] && [ "$li_already" -gt 0 ] && parts="${parts}already=${li_already},"
|
|
568
|
+
[ -n "$li_excl" ] && [ "$li_excl" -gt 0 ] && parts="${parts}excluded=${li_excl},"
|
|
569
|
+
[ -n "$li_unm" ] && [ "$li_unm" -gt 0 ] && parts="${parts}unmatched=${li_unm},"
|
|
570
|
+
LI_SCAN_ARG="${parts%,}"
|
|
571
|
+
fi
|
|
572
|
+
if [ -n "$LI_SCAN_ARG" ]; then
|
|
573
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "engage_linkedin" --posted "$TOTAL_REPLIED" --skipped "$TOTAL_SKIPPED" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED" --scan "$LI_SCAN_ARG"
|
|
574
|
+
else
|
|
575
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "engage_linkedin" --posted "$TOTAL_REPLIED" --skipped "$TOTAL_SKIPPED" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
|
|
576
|
+
fi
|
|
577
|
+
|
|
578
|
+
# Delete old logs
|
|
579
|
+
find "$LOG_DIR" -name "engage-linkedin-*.log" -mtime +7 -delete 2>/dev/null || true
|
|
580
|
+
|
|
581
|
+
log "=== LinkedIn engagement complete: $(date) ==="
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# engage-moltbook.sh — MoltBook reply engagement loop
|
|
3
|
+
# Calls engage_reddit.py --platform moltbook to process pending MoltBook replies.
|
|
4
|
+
# Discovery runs separately via run-scan-moltbook-replies.sh.
|
|
5
|
+
# Called by launchd every 10 minutes.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
source "$(dirname "$0")/lock.sh"
|
|
10
|
+
acquire_lock "engage-moltbook" 3600
|
|
11
|
+
|
|
12
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
13
|
+
|
|
14
|
+
# Cycle ID for cross-cycle cost accounting (see engage.sh / run-reddit-search.sh
|
|
15
|
+
# for the same pattern). engage_reddit.py's claude subprocess inherits this via
|
|
16
|
+
# env, and log_claude_session.py stamps claude_sessions.cycle_id.
|
|
17
|
+
BATCH_ID="${BATCH_ID:-enmb-$(date +%Y%m%d-%H%M%S)}"
|
|
18
|
+
export BATCH_ID
|
|
19
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
20
|
+
|
|
21
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
22
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
23
|
+
mkdir -p "$LOG_DIR"
|
|
24
|
+
LOG_FILE="$LOG_DIR/engage-moltbook-$(date +%Y-%m-%d_%H%M%S).log"
|
|
25
|
+
|
|
26
|
+
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
27
|
+
|
|
28
|
+
RUN_START=$(date +%s)
|
|
29
|
+
log "=== MoltBook Engage Run: $(date) ==="
|
|
30
|
+
|
|
31
|
+
python3 "$REPO_DIR/scripts/engage_reddit.py" --platform moltbook 2>&1 | tee -a "$LOG_FILE" || log "WARNING: engage_reddit.py exited non-zero"
|
|
32
|
+
|
|
33
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
34
|
+
log "=== MoltBook Engage complete: $(date) (elapsed ${RUN_ELAPSED}s) ==="
|
|
35
|
+
|
|
36
|
+
find "$LOG_DIR" -name "engage-moltbook-*.log" -mtime +7 -delete 2>/dev/null || true
|