@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,409 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build the personal-brand PERSONA from the user's own public footprint (step 2,
|
|
3
|
+
2026-06-26). Companion to scripts/saps_mode.py + the menu-bar toggle.
|
|
4
|
+
|
|
5
|
+
Personal-brand mode (the menu-bar toggle) drafts organic replies for the persona
|
|
6
|
+
project (config.json entry with `persona: true`). Those replies are only as good
|
|
7
|
+
as how well the persona is GROUNDED in who the user actually is. This script
|
|
8
|
+
assembles a grounding CORPUS from the user's own footprint so the persona's
|
|
9
|
+
description / content_angle / voice / search_topics reflect the real person, the
|
|
10
|
+
same way the original 2026-02 flow grounded every reply in "Matthew's work."
|
|
11
|
+
|
|
12
|
+
DESIGN (mirrors the onboarding profile_scan philosophy):
|
|
13
|
+
- This script GATHERS a corpus and emits `grounding_instructions`. It does NOT
|
|
14
|
+
synthesize voice/description by itself in the default mode — synthesis stays
|
|
15
|
+
in the conversation (or a deliberate --apply step) so the user reviews and
|
|
16
|
+
confirms before anything is written. Keeping a human in the loop is the whole
|
|
17
|
+
privacy contract here.
|
|
18
|
+
- PUBLICLY PUBLISHABLE ONLY. Every source is something the user has already
|
|
19
|
+
made public (a bio, public posts, a public repo, a public website) OR, for
|
|
20
|
+
the opt-in local/Chrome sources, reduced to non-identifying topical signal
|
|
21
|
+
(interest domains, not PII). Nothing private (cookies, passwords, private
|
|
22
|
+
files, contacts, message bodies) is ever read or emitted.
|
|
23
|
+
|
|
24
|
+
SOURCES (each best-effort; a failure is recorded and skipped, never fatal):
|
|
25
|
+
x the connected X account's bio + original posts + replies, via the
|
|
26
|
+
existing read-only scripts/scan_x_profile.py (managed Chrome :9555).
|
|
27
|
+
This is the strongest authentic-voice signal.
|
|
28
|
+
github a public GitHub profile: bio, top repos, languages, repo blurbs, via
|
|
29
|
+
the public REST API (no auth, public data only).
|
|
30
|
+
website a personal website URL: visible title/description/headings only.
|
|
31
|
+
provided arbitrary text blobs the caller passes with --source LABEL=<file>.
|
|
32
|
+
Use this to fold in footprint the host agent already gathered with the
|
|
33
|
+
linkedin-agent / reddit-agent (which need auth this script won't touch).
|
|
34
|
+
local OPT-IN (--include-local): a tiny allowlist of obviously-public files
|
|
35
|
+
(e.g. an about.md you point at with --local-file). Default OFF; prints
|
|
36
|
+
exactly what it would read.
|
|
37
|
+
chrome OPT-IN (--include-chrome): the SET OF DISTINCT DOMAINS in Chrome
|
|
38
|
+
history as an interest signal — no full URLs, no titles, no
|
|
39
|
+
timestamps, no PII. Default OFF; a loud warning is printed.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
# Gather a corpus (public sources) and print it for the agent/user to review:
|
|
43
|
+
python3 scripts/build_persona.py gather --github m13v --website https://m13v.com
|
|
44
|
+
|
|
45
|
+
# Fold in agent-gathered LinkedIn/Reddit text:
|
|
46
|
+
python3 scripts/build_persona.py gather --source linkedin=/tmp/li.txt --source reddit=/tmp/rd.txt
|
|
47
|
+
|
|
48
|
+
# Opt in to local / Chrome interest signal (prints what it reads first):
|
|
49
|
+
python3 scripts/build_persona.py gather --include-local --local-file ~/about.md --include-chrome
|
|
50
|
+
|
|
51
|
+
# Apply a REVIEWED persona (synthesized by the agent/user) to config + DB:
|
|
52
|
+
python3 scripts/build_persona.py apply --from /tmp/persona.json
|
|
53
|
+
where persona.json = {"description": "...", "content_angle": "...",
|
|
54
|
+
"voice": {...}, "search_topics": ["...", ...]}
|
|
55
|
+
|
|
56
|
+
Read-only in `gather`. `apply` writes ONLY the persona project's grounding fields
|
|
57
|
+
in config.json and seeds its search_topics into the DB (via seed_search_topics).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import argparse
|
|
63
|
+
import json
|
|
64
|
+
import os
|
|
65
|
+
import shutil
|
|
66
|
+
import sqlite3
|
|
67
|
+
import subprocess
|
|
68
|
+
import sys
|
|
69
|
+
import tempfile
|
|
70
|
+
import urllib.request
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
|
|
73
|
+
HERE = Path(__file__).resolve().parent
|
|
74
|
+
sys.path.insert(0, str(HERE))
|
|
75
|
+
|
|
76
|
+
import saps_mode # noqa: E402 (config/persona resolution, shared source of truth)
|
|
77
|
+
|
|
78
|
+
PUBLIC_ONLY_NOTE = (
|
|
79
|
+
"PUBLICLY PUBLISHABLE ONLY: every field below must be safe to post in "
|
|
80
|
+
"public. Use only what the user has already made public (bios, public "
|
|
81
|
+
"posts, public repos, a public site) or non-identifying interest signal. "
|
|
82
|
+
"Never include private data, contact details, or anything the user would "
|
|
83
|
+
"not say to a stranger."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
GROUNDING_INSTRUCTIONS = (
|
|
87
|
+
"You are grounding the user's PERSONAL-BRAND persona from their own public "
|
|
88
|
+
"footprint. From the corpus, synthesize four fields and CONFIRM with the "
|
|
89
|
+
"user before applying:\n"
|
|
90
|
+
" description 2-3 sentences: who this person is as a builder/voice.\n"
|
|
91
|
+
" content_angle one paragraph of concrete, first-hand experience the "
|
|
92
|
+
"persona can speak from (real projects, real numbers, real pain).\n"
|
|
93
|
+
" voice {tone, never[]}: how they actually write (read their own "
|
|
94
|
+
"posts/replies in the x source). Keep the organic rules: first person, "
|
|
95
|
+
"specific, no links, no feature lists, no sales, no em dashes.\n"
|
|
96
|
+
" search_topics ~15 topics they have genuine experience with.\n"
|
|
97
|
+
" content_corpus (OPTIONAL but STRONGLY encouraged) the RAW gathered "
|
|
98
|
+
"corpus as one plain-text block: the persona's actual posts, replies, repo "
|
|
99
|
+
"descriptions, site copy, verbatim. This is NOT synthesized. It becomes the "
|
|
100
|
+
"grounding pool the drafter quotes real specifics from, so keep it dense and "
|
|
101
|
+
"first-hand. Trim only obvious noise; do NOT paraphrase. Cap ~8000 chars.\n"
|
|
102
|
+
+ PUBLIC_ONLY_NOTE
|
|
103
|
+
+ "\nThen write the fields to /tmp/persona.json and run "
|
|
104
|
+
"`build_persona.py apply --from /tmp/persona.json`, or hand them to the "
|
|
105
|
+
"project_config tool for the persona project."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
# Sources
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
def _gather_x(handle: str | None, posts: int, comments: int) -> dict:
|
|
113
|
+
"""Reuse the read-only X profile scanner (managed Chrome). Best-effort."""
|
|
114
|
+
script = HERE / "scan_x_profile.py"
|
|
115
|
+
if not script.exists():
|
|
116
|
+
return {"ok": False, "error": "scan_x_profile.py not found"}
|
|
117
|
+
py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
|
|
118
|
+
cmd = [py, str(script), "--posts", str(posts), "--comments", str(comments)]
|
|
119
|
+
if handle:
|
|
120
|
+
cmd += ["--handle", handle]
|
|
121
|
+
try:
|
|
122
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
return {"ok": False, "error": f"scan_x_profile failed: {e}"}
|
|
125
|
+
# The scanner prints a JSON object as its last stdout line.
|
|
126
|
+
last = ""
|
|
127
|
+
for line in (res.stdout or "").splitlines():
|
|
128
|
+
line = line.strip()
|
|
129
|
+
if line.startswith("{") and line.endswith("}"):
|
|
130
|
+
last = line
|
|
131
|
+
if not last:
|
|
132
|
+
return {"ok": False, "error": "scan_x_profile produced no JSON",
|
|
133
|
+
"stderr_tail": (res.stderr or "")[-300:]}
|
|
134
|
+
try:
|
|
135
|
+
obj = json.loads(last)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
return {"ok": False, "error": f"scan_x_profile JSON parse: {e}"}
|
|
138
|
+
return obj
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _http_json(url: str, timeout: int = 20):
|
|
142
|
+
req = urllib.request.Request(
|
|
143
|
+
url, headers={"User-Agent": "s4l-build-persona", "Accept": "application/vnd.github+json"}
|
|
144
|
+
)
|
|
145
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
146
|
+
return json.loads(r.read().decode("utf-8", "replace"))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _gather_github(user: str) -> dict:
|
|
150
|
+
"""Public GitHub profile + top repos. No auth (public data only)."""
|
|
151
|
+
try:
|
|
152
|
+
prof = _http_json(f"https://api.github.com/users/{user}")
|
|
153
|
+
repos = _http_json(
|
|
154
|
+
f"https://api.github.com/users/{user}/repos?sort=pushed&per_page=20"
|
|
155
|
+
)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
return {"ok": False, "error": f"github fetch failed: {e}"}
|
|
158
|
+
top = []
|
|
159
|
+
for r in sorted(repos, key=lambda x: x.get("stargazers_count", 0), reverse=True)[:12]:
|
|
160
|
+
top.append({
|
|
161
|
+
"name": r.get("name"),
|
|
162
|
+
"description": r.get("description"),
|
|
163
|
+
"language": r.get("language"),
|
|
164
|
+
"stars": r.get("stargazers_count"),
|
|
165
|
+
"topics": r.get("topics") or [],
|
|
166
|
+
})
|
|
167
|
+
return {
|
|
168
|
+
"ok": True,
|
|
169
|
+
"login": prof.get("login"),
|
|
170
|
+
"name": prof.get("name"),
|
|
171
|
+
"bio": prof.get("bio"),
|
|
172
|
+
"blog": prof.get("blog"),
|
|
173
|
+
"followers": prof.get("followers"),
|
|
174
|
+
"public_repos": prof.get("public_repos"),
|
|
175
|
+
"top_repos": top,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _gather_website(url: str) -> dict:
|
|
180
|
+
"""Visible title/description/headings of a public page. Best-effort, no JS."""
|
|
181
|
+
import html
|
|
182
|
+
import re
|
|
183
|
+
try:
|
|
184
|
+
req = urllib.request.Request(url, headers={"User-Agent": "s4l-build-persona"})
|
|
185
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
186
|
+
raw = r.read(400_000).decode("utf-8", "replace")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
return {"ok": False, "error": f"website fetch failed: {e}"}
|
|
189
|
+
|
|
190
|
+
def _find(pat):
|
|
191
|
+
m = re.search(pat, raw, re.I | re.S)
|
|
192
|
+
return html.unescape(re.sub(r"<[^>]+>", "", m.group(1)).strip()) if m else None
|
|
193
|
+
|
|
194
|
+
title = _find(r"<title[^>]*>(.*?)</title>")
|
|
195
|
+
desc = None
|
|
196
|
+
m = re.search(r'<meta[^>]+name=["\']description["\'][^>]+content=["\'](.*?)["\']', raw, re.I)
|
|
197
|
+
if m:
|
|
198
|
+
desc = html.unescape(m.group(1).strip())
|
|
199
|
+
heads = [
|
|
200
|
+
html.unescape(re.sub(r"<[^>]+>", "", h).strip())
|
|
201
|
+
for h in re.findall(r"<h[12][^>]*>(.*?)</h[12]>", raw, re.I | re.S)
|
|
202
|
+
]
|
|
203
|
+
heads = [h for h in heads if h][:15]
|
|
204
|
+
return {"ok": True, "url": url, "title": title, "description": desc, "headings": heads}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _gather_provided(specs: list[str]) -> list[dict]:
|
|
208
|
+
"""--source LABEL=path: fold in text the agent already gathered."""
|
|
209
|
+
out = []
|
|
210
|
+
for spec in specs or []:
|
|
211
|
+
if "=" not in spec:
|
|
212
|
+
out.append({"ok": False, "error": f"bad --source {spec!r} (need LABEL=path)"})
|
|
213
|
+
continue
|
|
214
|
+
label, path = spec.split("=", 1)
|
|
215
|
+
try:
|
|
216
|
+
text = Path(path).expanduser().read_text(errors="replace")
|
|
217
|
+
out.append({"ok": True, "label": label, "text": text[:20_000]})
|
|
218
|
+
except Exception as e:
|
|
219
|
+
out.append({"ok": False, "label": label, "error": str(e)})
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _gather_local(files: list[str]) -> dict:
|
|
224
|
+
"""OPT-IN. Read a short allowlist of caller-named, obviously-public files."""
|
|
225
|
+
read = []
|
|
226
|
+
for f in files or []:
|
|
227
|
+
p = Path(f).expanduser()
|
|
228
|
+
print(f"[build_persona] local: reading {p}", file=sys.stderr)
|
|
229
|
+
try:
|
|
230
|
+
read.append({"ok": True, "path": str(p), "text": p.read_text(errors="replace")[:20_000]})
|
|
231
|
+
except Exception as e:
|
|
232
|
+
read.append({"ok": False, "path": str(p), "error": str(e)})
|
|
233
|
+
return {"ok": True, "files": read, "note": "only files you explicitly passed with --local-file"}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _gather_chrome_domains(limit: int = 80) -> dict:
|
|
237
|
+
"""OPT-IN. Distinct domains from Chrome history as an INTEREST signal only.
|
|
238
|
+
|
|
239
|
+
No full URLs, no page titles, no timestamps, no PII. Reads a temp COPY of the
|
|
240
|
+
history DB (Chrome locks the live file), counts visits per host, returns the
|
|
241
|
+
top hosts. This is interest-level signal (what topics the user follows), the
|
|
242
|
+
kind of thing already inferable from their public posts.
|
|
243
|
+
"""
|
|
244
|
+
print(
|
|
245
|
+
"[build_persona] WARNING: --include-chrome reads your Chrome history to "
|
|
246
|
+
"extract DISTINCT DOMAINS only (no URLs/titles/PII). Ctrl-C now to abort.",
|
|
247
|
+
file=sys.stderr,
|
|
248
|
+
)
|
|
249
|
+
base = Path.home() / "Library/Application Support/Google/Chrome"
|
|
250
|
+
candidates = [base / "Default/History"] + sorted(base.glob("Profile */History"))
|
|
251
|
+
src = next((c for c in candidates if c.exists()), None)
|
|
252
|
+
if not src:
|
|
253
|
+
return {"ok": False, "error": "no Chrome History DB found"}
|
|
254
|
+
from urllib.parse import urlparse
|
|
255
|
+
from collections import Counter
|
|
256
|
+
tmp = Path(tempfile.gettempdir()) / "s4l_chrome_history_copy.sqlite"
|
|
257
|
+
try:
|
|
258
|
+
shutil.copy2(src, tmp)
|
|
259
|
+
con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
260
|
+
rows = con.execute("SELECT url, visit_count FROM urls").fetchall()
|
|
261
|
+
con.close()
|
|
262
|
+
except Exception as e:
|
|
263
|
+
return {"ok": False, "error": f"chrome history read failed: {e}"}
|
|
264
|
+
finally:
|
|
265
|
+
try:
|
|
266
|
+
tmp.unlink()
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
hosts: "Counter[str]" = Counter()
|
|
270
|
+
for url, vc in rows:
|
|
271
|
+
host = (urlparse(url).hostname or "").lstrip("www.")
|
|
272
|
+
if host and "." in host:
|
|
273
|
+
hosts[host] += int(vc or 1)
|
|
274
|
+
top = [{"domain": h, "weight": w} for h, w in hosts.most_common(limit)]
|
|
275
|
+
return {"ok": True, "top_domains": top,
|
|
276
|
+
"note": "interest signal only: distinct domains, no URLs/titles/PII"}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --------------------------------------------------------------------------- #
|
|
280
|
+
# gather / apply
|
|
281
|
+
# --------------------------------------------------------------------------- #
|
|
282
|
+
def cmd_gather(args) -> int:
|
|
283
|
+
sources: dict = {}
|
|
284
|
+
sources["x"] = _gather_x(args.handle, args.posts, args.comments)
|
|
285
|
+
if args.github:
|
|
286
|
+
sources["github"] = _gather_github(args.github)
|
|
287
|
+
if args.website:
|
|
288
|
+
sources["website"] = _gather_website(args.website)
|
|
289
|
+
if args.source:
|
|
290
|
+
sources["provided"] = _gather_provided(args.source)
|
|
291
|
+
if args.include_local:
|
|
292
|
+
sources["local"] = _gather_local(args.local_file)
|
|
293
|
+
if args.include_chrome:
|
|
294
|
+
sources["chrome"] = _gather_chrome_domains()
|
|
295
|
+
|
|
296
|
+
corpus = {
|
|
297
|
+
"ok": True,
|
|
298
|
+
"persona_project": saps_mode.persona_name() or "(none configured)",
|
|
299
|
+
"public_only": True,
|
|
300
|
+
"sources": sources,
|
|
301
|
+
"grounding_instructions": GROUNDING_INSTRUCTIONS,
|
|
302
|
+
}
|
|
303
|
+
print(json.dumps(corpus, ensure_ascii=False, indent=2))
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def cmd_apply(args) -> int:
|
|
308
|
+
"""Write a REVIEWED persona into config.json + seed its topics into the DB."""
|
|
309
|
+
try:
|
|
310
|
+
data = json.loads(Path(args.from_file).read_text())
|
|
311
|
+
except Exception as e:
|
|
312
|
+
print(f"could not read --from {args.from_file!r}: {e}", file=sys.stderr)
|
|
313
|
+
return 2
|
|
314
|
+
|
|
315
|
+
name = saps_mode.persona_name()
|
|
316
|
+
if not name:
|
|
317
|
+
print("no persona project (persona:true) in config.json", file=sys.stderr)
|
|
318
|
+
return 2
|
|
319
|
+
|
|
320
|
+
cfg_path = saps_mode.config_path()
|
|
321
|
+
cfg = json.loads(cfg_path.read_text())
|
|
322
|
+
proj = next((p for p in cfg.get("projects", []) if p.get("name") == name), None)
|
|
323
|
+
if proj is None:
|
|
324
|
+
print(f"persona project {name!r} vanished from config", file=sys.stderr)
|
|
325
|
+
return 2
|
|
326
|
+
|
|
327
|
+
# Merge ONLY the grounding fields; never touch enabled/persona/weight or any
|
|
328
|
+
# marketing field (the persona must stay link-free and out of the promo pick).
|
|
329
|
+
changed = []
|
|
330
|
+
for field in ("description", "content_angle", "voice"):
|
|
331
|
+
if field in data and data[field]:
|
|
332
|
+
proj[field] = data[field]
|
|
333
|
+
changed.append(field)
|
|
334
|
+
topics = data.get("search_topics")
|
|
335
|
+
if isinstance(topics, list) and topics:
|
|
336
|
+
proj["search_topics"] = [str(t).strip() for t in topics if str(t).strip()]
|
|
337
|
+
changed.append("search_topics")
|
|
338
|
+
|
|
339
|
+
# Raw corpus -> sidecar file (NOT config.json). config.json is inlined into
|
|
340
|
+
# many prompts (ALL_PROJECTS_JSON), so a multi-KB corpus there would bloat
|
|
341
|
+
# every cycle's token bill. Instead persist it beside config.json and let the
|
|
342
|
+
# persona lane read it only when it actually drafts. cmd_apply is the single
|
|
343
|
+
# writer; the cycle is read-only.
|
|
344
|
+
corpus_written = None
|
|
345
|
+
corpus = data.get("content_corpus")
|
|
346
|
+
if isinstance(corpus, str) and corpus.strip():
|
|
347
|
+
corpus_path = cfg_path.parent / "persona_corpus.txt"
|
|
348
|
+
try:
|
|
349
|
+
corpus_path.write_text(corpus.strip()[:8000] + "\n")
|
|
350
|
+
corpus_written = str(corpus_path)
|
|
351
|
+
changed.append("content_corpus")
|
|
352
|
+
except Exception as e:
|
|
353
|
+
print(f"[build_persona] corpus sidecar write failed: {e}", file=sys.stderr)
|
|
354
|
+
|
|
355
|
+
if args.dry_run:
|
|
356
|
+
print(json.dumps({"would_update": name, "fields": changed,
|
|
357
|
+
"search_topics": proj.get("search_topics"),
|
|
358
|
+
"corpus_sidecar": corpus_written}, indent=2))
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
tmp = cfg_path.with_suffix(".json.tmp")
|
|
362
|
+
tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n")
|
|
363
|
+
tmp.replace(cfg_path)
|
|
364
|
+
print(f"[build_persona] updated config.json persona {name!r}: {', '.join(changed)}")
|
|
365
|
+
if corpus_written:
|
|
366
|
+
print(f"[build_persona] wrote raw corpus sidecar -> {corpus_written}")
|
|
367
|
+
|
|
368
|
+
# Seed the (possibly new) topics into project_search_topics via the canonical
|
|
369
|
+
# path, so pick_search_topic has a live universe for the persona.
|
|
370
|
+
if "search_topics" in changed:
|
|
371
|
+
seed = HERE / "seed_search_topics.py"
|
|
372
|
+
py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
|
|
373
|
+
try:
|
|
374
|
+
r = subprocess.run([py, str(seed), "--project", name],
|
|
375
|
+
capture_output=True, text=True, timeout=120)
|
|
376
|
+
print((r.stdout or r.stderr or "").strip()[-400:])
|
|
377
|
+
except Exception as e:
|
|
378
|
+
print(f"[build_persona] topic seed failed (run manually): {e}", file=sys.stderr)
|
|
379
|
+
return 0
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def main(argv) -> int:
|
|
383
|
+
ap = argparse.ArgumentParser(description="Build the personal-brand persona from public footprint")
|
|
384
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
385
|
+
|
|
386
|
+
g = sub.add_parser("gather", help="gather a grounding corpus (read-only)")
|
|
387
|
+
g.add_argument("--handle", default=None, help="X @handle (default: live logged-in handle)")
|
|
388
|
+
g.add_argument("--posts", type=int, default=20)
|
|
389
|
+
g.add_argument("--comments", type=int, default=50)
|
|
390
|
+
g.add_argument("--github", default=None, help="public GitHub username")
|
|
391
|
+
g.add_argument("--website", default=None, help="personal website URL")
|
|
392
|
+
g.add_argument("--source", action="append", default=[], metavar="LABEL=path",
|
|
393
|
+
help="fold in agent-gathered text (repeatable)")
|
|
394
|
+
g.add_argument("--include-local", action="store_true", help="opt in to reading --local-file files")
|
|
395
|
+
g.add_argument("--local-file", action="append", default=[], help="a public file to include (repeatable)")
|
|
396
|
+
g.add_argument("--include-chrome", action="store_true", help="opt in to Chrome interest domains")
|
|
397
|
+
g.set_defaults(func=cmd_gather)
|
|
398
|
+
|
|
399
|
+
a = sub.add_parser("apply", help="write a reviewed persona to config + DB")
|
|
400
|
+
a.add_argument("--from", dest="from_file", required=True, help="persona JSON file")
|
|
401
|
+
a.add_argument("--dry-run", action="store_true", help="show the change, write nothing")
|
|
402
|
+
a.set_defaults(func=cmd_apply)
|
|
403
|
+
|
|
404
|
+
args = ap.parse_args(argv)
|
|
405
|
+
return args.func(args)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bulk-record ICP prechecks for one dm in a single process.
|
|
3
|
+
Usage: bulk_icp.py DM_ID 'project=label:notes' 'project2=label2:notes2' ...
|
|
4
|
+
label in {icp_match,icp_miss,disqualified,unknown}
|
|
5
|
+
"""
|
|
6
|
+
import sys, subprocess
|
|
7
|
+
dm_id=sys.argv[1]
|
|
8
|
+
for arg in sys.argv[2:]:
|
|
9
|
+
proj, rest = arg.split('=',1)
|
|
10
|
+
if ':' in rest:
|
|
11
|
+
label, notes = rest.split(':',1)
|
|
12
|
+
else:
|
|
13
|
+
label, notes = rest, ''
|
|
14
|
+
cmd=['python3','scripts/dm_conversation.py','set-icp-precheck','--dm-id',dm_id,
|
|
15
|
+
'--project',proj,'--label',label]
|
|
16
|
+
if notes: cmd += ['--notes',notes]
|
|
17
|
+
r=subprocess.run(cmd,capture_output=True,text=True)
|
|
18
|
+
print(proj,label,'->',('ok' if r.returncode==0 else 'ERR '+r.stderr.strip()[:80]))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Attach a single outbound action to a campaign and increment its counter.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 campaign_bump.py --table posts --id 123 --campaign-id 3
|
|
6
|
+
python3 campaign_bump.py --table replies --id 456 --campaign-id 3
|
|
7
|
+
python3 campaign_bump.py --table dm_messages --id 789 --campaign-id 3
|
|
8
|
+
|
|
9
|
+
The named row's campaign_id column is set to the given campaign, and the
|
|
10
|
+
campaign's posts_made counter advances by one. Idempotent: if the row already
|
|
11
|
+
references this campaign, no counter bump happens.
|
|
12
|
+
|
|
13
|
+
HTTP-only lane (2026-06-01): routes through /api/v1/campaigns/bump. No
|
|
14
|
+
DATABASE_URL, no db.get_conn(), no fallback.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
|
|
23
|
+
ALLOWED_TABLES = {"posts", "replies", "dm_messages"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bump_via_api(table, row_id, campaign_id):
|
|
27
|
+
from http_api import api_post
|
|
28
|
+
resp = api_post(
|
|
29
|
+
"/api/v1/campaigns/bump",
|
|
30
|
+
{"table": table, "id": int(row_id), "campaign_id": int(campaign_id)},
|
|
31
|
+
)
|
|
32
|
+
data = (resp or {}).get("data") or {}
|
|
33
|
+
bumped = bool(data.get("bumped"))
|
|
34
|
+
return bumped
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main():
|
|
38
|
+
ap = argparse.ArgumentParser()
|
|
39
|
+
ap.add_argument("--table", required=True, choices=sorted(ALLOWED_TABLES))
|
|
40
|
+
ap.add_argument("--id", type=int, required=True)
|
|
41
|
+
ap.add_argument("--campaign-id", type=int, required=True)
|
|
42
|
+
args = ap.parse_args()
|
|
43
|
+
|
|
44
|
+
bumped = _bump_via_api(args.table, args.id, args.campaign_id)
|
|
45
|
+
|
|
46
|
+
print(f"table={args.table} id={args.id} campaign={args.campaign_id} bumped={bumped}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
sys.exit(main())
|