@m13v/s4l 1.6.197-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,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())