@m13v/s4l 1.7.6-rc.26 → 1.7.6-rc.28
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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/amplitude_24h_signups.py +7 -4
- package/scripts/amplitude_signups.py +7 -4
- package/scripts/dm_conversation.py +7 -6
- package/scripts/engage_github.py +7 -4
- package/scripts/engage_reddit.py +7 -4
- package/scripts/feedback_digest.py +25 -7
- package/scripts/find_threads.py +7 -6
- package/scripts/github_tools.py +7 -4
- package/scripts/learned_preferences.py +79 -36
- package/scripts/pick_project.py +7 -4
- package/scripts/pick_thread_target.py +7 -4
- package/scripts/pick_twitter_thread_target.py +7 -4
- package/scripts/post_github.py +7 -4
- package/scripts/post_reddit.py +7 -4
- package/scripts/project_stats.py +7 -4
- package/scripts/run_moltbook_cycle.py +7 -4
- package/scripts/scan_dm_candidates.py +7 -6
- package/scripts/scan_github_replies.py +7 -6
- package/scripts/scan_moltbook_replies.py +7 -6
- package/scripts/scan_reddit_replies.py +7 -6
- package/scripts/scan_twitter_mentions_browser.py +7 -6
- package/scripts/scan_twitter_thread_followups.py +7 -6
- package/scripts/stats.py +7 -6
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.6-rc.
|
|
5
|
+
"version": "1.7.6-rc.28",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.6-rc.
|
|
3
|
+
"version": "1.7.6-rc.28",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -80,7 +80,13 @@ import zipfile
|
|
|
80
80
|
from datetime import datetime, timedelta, timezone
|
|
81
81
|
|
|
82
82
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
83
|
-
|
|
83
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
84
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
85
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
86
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
87
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
88
|
+
from config import config_path as _canonical_config_path, load_config
|
|
89
|
+
CONFIG_PATH = _canonical_config_path()
|
|
84
90
|
ENV_PATH = os.path.join(REPO_ROOT, ".env")
|
|
85
91
|
CACHE_DIR = os.path.join(REPO_ROOT, "skill", "cache")
|
|
86
92
|
CACHE_PATH = os.path.join(CACHE_DIR, "amplitude_24h_signups.json")
|
|
@@ -110,9 +116,6 @@ def load_env():
|
|
|
110
116
|
os.environ.setdefault(k.strip(), v.strip())
|
|
111
117
|
|
|
112
118
|
|
|
113
|
-
def load_config():
|
|
114
|
-
with open(CONFIG_PATH) as f:
|
|
115
|
-
return json.load(f)
|
|
116
119
|
|
|
117
120
|
|
|
118
121
|
def keychain_get(name):
|
|
@@ -29,7 +29,13 @@ import urllib.request
|
|
|
29
29
|
from datetime import datetime, timedelta, timezone
|
|
30
30
|
|
|
31
31
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
-
|
|
32
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
33
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
34
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
35
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
36
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
37
|
+
from config import config_path as _canonical_config_path, load_config
|
|
38
|
+
CONFIG_PATH = _canonical_config_path()
|
|
33
39
|
ENV_PATH = os.path.join(REPO_ROOT, ".env")
|
|
34
40
|
API_BASE = "https://amplitude.com/api/2/events/segmentation"
|
|
35
41
|
|
|
@@ -45,9 +51,6 @@ def load_env():
|
|
|
45
51
|
os.environ.setdefault(k.strip(), v.strip())
|
|
46
52
|
|
|
47
53
|
|
|
48
|
-
def load_config():
|
|
49
|
-
with open(CONFIG_PATH) as f:
|
|
50
|
-
return json.load(f)
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
def fetch_signup_series(api_key, secret_key, signup_event, attribution_filter, start, end):
|
|
@@ -42,7 +42,13 @@ import sys
|
|
|
42
42
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
43
43
|
import db as dbmod
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
46
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
47
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
48
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
49
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
50
|
+
from config import config_path as _canonical_config_path, load_config
|
|
51
|
+
CONFIG_PATH = _canonical_config_path()
|
|
46
52
|
|
|
47
53
|
|
|
48
54
|
def _valid_chat_url(platform, url):
|
|
@@ -76,11 +82,6 @@ def _valid_chat_url(platform, url):
|
|
|
76
82
|
return u
|
|
77
83
|
|
|
78
84
|
|
|
79
|
-
def load_config():
|
|
80
|
-
if os.path.exists(CONFIG_PATH):
|
|
81
|
-
with open(CONFIG_PATH) as f:
|
|
82
|
-
return json.load(f)
|
|
83
|
-
return {}
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
def get_our_account(config, platform):
|
package/scripts/engage_github.py
CHANGED
|
@@ -40,7 +40,13 @@ except Exception: # never let a missing module break the engage lane
|
|
|
40
40
|
return ""
|
|
41
41
|
|
|
42
42
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
43
|
-
|
|
43
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
44
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
45
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
46
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
47
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
48
|
+
from config import config_path as _canonical_config_path, load_config
|
|
49
|
+
CONFIG_PATH = _canonical_config_path()
|
|
44
50
|
REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
|
|
45
51
|
SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
|
|
46
52
|
|
|
@@ -60,9 +66,6 @@ os.environ["S4L_PYTHON"] = PYTHON
|
|
|
60
66
|
THREAD_CHAR_CAP = 12000
|
|
61
67
|
|
|
62
68
|
|
|
63
|
-
def load_config():
|
|
64
|
-
with open(CONFIG_PATH) as f:
|
|
65
|
-
return json.load(f)
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
def get_next_pending():
|
package/scripts/engage_reddit.py
CHANGED
|
@@ -27,7 +27,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
27
27
|
from http_api import api_get, api_post
|
|
28
28
|
|
|
29
29
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
30
|
-
|
|
30
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
31
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
32
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
33
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
34
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
35
|
+
from config import config_path as _canonical_config_path, load_config
|
|
36
|
+
CONFIG_PATH = _canonical_config_path()
|
|
31
37
|
REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
|
|
32
38
|
CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
|
|
33
39
|
REDDIT_MCP_CONFIG = os.path.expanduser("~/.claude/browser-agent-configs/reddit-agent-mcp.json")
|
|
@@ -110,9 +116,6 @@ def _release_browser_lease() -> None:
|
|
|
110
116
|
pass
|
|
111
117
|
|
|
112
118
|
|
|
113
|
-
def load_config():
|
|
114
|
-
with open(CONFIG_PATH) as f:
|
|
115
|
-
return json.load(f)
|
|
116
119
|
|
|
117
120
|
|
|
118
121
|
def load_active_reddit_campaigns():
|
|
@@ -312,29 +312,29 @@ CURRENT learned_preferences (shared by every project):
|
|
|
312
312
|
CURRENT voice.never: {json.dumps(voice_never)}
|
|
313
313
|
CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}{edit_examples_block}
|
|
314
314
|
|
|
315
|
-
NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(
|
|
315
|
+
NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(approved)} approved, {len(loved)} of the approvals loved, {len(plat_removed)} platform moderation strikes):
|
|
316
316
|
{ev_lines}{overall_block}{platform_block}
|
|
317
317
|
|
|
318
|
-
Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note.
|
|
318
|
+
Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. (Rejects with no category and no note are filtered out before this prompt, so every reject below carries a real reason.) "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; even a single edit that shows a clear correction (a phrase type removed, a structure replaced, tone shifted, length cut) is enough to distill into draft_style_notes, and several agreeing only make it stronger. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
|
|
319
319
|
|
|
320
|
-
Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat
|
|
320
|
+
Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat it as weak, corroborating a pattern that stronger events already show rather than founding one on its own). "second_draft_not_read" means they approved the default without touching or reading the alternative: NO pairwise signal, never infer anything against the unread draft. A single pairwise choice is enough: diff the picked text against the not-picked one and distill WHAT differs (tone, structure, length, opener type, directness) into draft_style_notes, and several agreeing only make it stronger; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
|
|
321
321
|
|
|
322
|
-
"[platform_removed]" and "[platform_banned]" events are MACHINE-DERIVED moderation outcomes, not human card decisions; weigh them on their own track, never as a statement of the reviewer's taste. platform_removed means a moderator or platform filter removed one of our posts: the lesson is the CONTENT pattern (what reads as promotional, off-topic, or AI-written in that kind of venue), feeding thread_avoid or draft_style_notes.
|
|
322
|
+
"[platform_removed]" and "[platform_banned]" events are MACHINE-DERIVED moderation outcomes, not human card decisions; weigh them on their own track, never as a statement of the reviewer's taste. platform_removed means a moderator or platform filter removed one of our posts: the lesson is the CONTENT pattern (what reads as promotional, off-topic, or AI-written in that kind of venue), feeding thread_avoid or draft_style_notes. A removal is a hard, machine-known fact (not an inferred taste signal), so a SINGLE removal is enough: treat it like a reasoned reject and distill the content-pattern lesson right away, since the moderation context states the venue and outcome explicitly; more removals sharing a pattern only make it stronger. platform_banned means a community blocked our account outright: the strongest platform-side evidence, but the specific venue is ALREADY hard-blocked by the pipeline, so never spend an entry naming an individual community; the only lesson worth an entry is a venue TYPE pattern (e.g. what kinds of communities keep rejecting us), and a single ban or removal is enough to name that TYPE. Platform events never justify block_authors (there is no author at fault) and never touch audience_prefer.
|
|
323
323
|
|
|
324
324
|
You can also block SPECIFIC authors via the plan's block_authors list. A block is a permanent hard exclusion of that one handle from all future thread selection, so it is YOUR judgment call, never automatic. Block when the evidence is strong: a wrong_author reject IS a direct human statement about that author (especially with profile_click), and the author context (author_followers, their post, found_via_topic) or the user's note confirms the account itself was the problem rather than the topic. Do NOT block when the reject looks topic-driven (off_topic/bad_draft on a reasonable account) or when you are unsure; the generalizable TYPE entry in audience_avoid is the softer tool for that.
|
|
325
325
|
|
|
326
326
|
Propose changes to the block. RULES, in priority order:
|
|
327
327
|
1. Be conservative. Prefer NO changes over speculative ones. An empty plan is a good plan when the evidence is thin.
|
|
328
|
-
2. A SINGLE reasoned event is enough to justify an entry; you do NOT need multiple events agreeing. When a reject carries a category, a note, a profile-click, or clear author/thread context, or an approval is loved, or the user edited the draft, or they made an explicit two-draft choice, distill the lesson right away into the most fitting list, generalized to the TYPE (never an individual handle or a one-off fact). More events agreeing only make the pattern stronger; they are not a precondition. OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one even from a single line, rewritten as a standing preference.
|
|
328
|
+
2. A SINGLE reasoned event is enough to justify an entry; you do NOT need multiple events agreeing. When a reject carries a category, a note, a profile-click, or clear author/thread context, or an approval is loved, or the user edited the draft, or they made an explicit two-draft choice, distill the lesson right away into the most fitting list, generalized to the TYPE (never an individual handle or a one-off fact). More events agreeing only make the pattern stronger; they are not a precondition. OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one even from a single line, rewritten as a standing preference.
|
|
329
329
|
3. Describe author/audience TYPES, never individual handles. "crypto/web3-native accounts shilling tokens" is right; "@someguy" is wrong. Preferences must generalize.
|
|
330
330
|
4. Approvals are counter-evidence. If approvals contradict an existing entry, propose removing or narrowing it. Also propose removing entries that events show are stale.
|
|
331
|
-
5. bad_draft events feed draft_style_notes (or,
|
|
331
|
+
5. bad_draft events feed draft_style_notes (or, for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add). voice.never and content_guardrails.do_not are the CURRENT project's curated fields, shown above; you may ADD to them and also REMOVE entries (voice_never_remove / guardrails_do_not_remove) when an approval or the user's feedback shows an entry is now wrong or stale. There is no cap. remove values must match an existing entry exactly.
|
|
332
332
|
6. Keep each entry plain language, no em dashes, no hashtags, understandable a month from now without these events. There is NO length or per-list count cap. A draft_style_notes entry SHOULD embed a short before/after fragment from the edit examples when one fits (e.g. rewrote 'X' to 'Y'); that anchor is the only way the concrete rewrite reaches the drafting model. audience_avoid, audience_prefer, and thread_avoid stay short type descriptions with no example.
|
|
333
333
|
7. Never propose a note that tells the writer to drop, strip, omit, or leave out the product link or url, or to remove trailing punctuation. Those contradict the tail-link bridge feature and are auto-rejected on write, so spend no entry on them even if an edit example shows the user deleting a link.
|
|
334
334
|
8. Scope check (this block is shared across every project): before proposing an entry, ask whether it would still make sense read from a totally different product's cards. If it only makes sense for "{project.get('name')}" specifically, don't add it.
|
|
335
335
|
|
|
336
336
|
OUTPUT: a single JSON object, nothing else. Schema:
|
|
337
|
-
{{"changes": {{"audience_avoid": {{"add": [], "remove": []}}, "audience_prefer": {{"add": [], "remove": []}}, "thread_avoid": {{"add": [], "remove": []}}, "draft_style_notes": {{"add": [], "remove": []}}}}, "voice_never_add": [], "guardrails_do_not_add": [], "block_authors": [{{"handle": "somehandle", "reason": "one short sentence citing the evidence"}}], "rationale": "one short sentence"}}
|
|
337
|
+
{{"changes": {{"audience_avoid": {{"add": [], "remove": []}}, "audience_prefer": {{"add": [], "remove": []}}, "thread_avoid": {{"add": [], "remove": []}}, "draft_style_notes": {{"add": [], "remove": []}}}}, "voice_never_add": [], "voice_never_remove": [], "guardrails_do_not_add": [], "guardrails_do_not_remove": [], "block_authors": [{{"handle": "somehandle", "reason": "one short sentence citing the evidence"}}], "rationale": "one short sentence"}}
|
|
338
338
|
"remove" values must match existing entries EXACTLY. Omit empty keys if you like; an all-empty plan means "no changes"."""
|
|
339
339
|
|
|
340
340
|
|
|
@@ -492,6 +492,24 @@ def digest_project(project: dict, platform: str, dry_run: bool,
|
|
|
492
492
|
f"count={len(no_reason)} threshold={BULK_NO_REASON_THRESHOLD} "
|
|
493
493
|
f"event_ids=[{ids}] (volume signal, logged regardless of whether "
|
|
494
494
|
"the digest model finds a shared textual pattern below)")
|
|
495
|
+
# no_reason_given rejects (no category, no note): the user rejected but left
|
|
496
|
+
# no learnable reason, so DROP them from the digest entirely (2026-07-17 user
|
|
497
|
+
# rule). Mark them processed so they never clog the small fetch window, then
|
|
498
|
+
# remove them from the working set before any actionability / prompt logic.
|
|
499
|
+
if no_reason:
|
|
500
|
+
nr_ids = [int(e["id"]) for e in no_reason
|
|
501
|
+
if isinstance(e.get("id"), int) or str(e.get("id", "")).isdigit()]
|
|
502
|
+
if nr_ids and not dry_run:
|
|
503
|
+
try:
|
|
504
|
+
api_patch("/api/v1/review-events",
|
|
505
|
+
{"ids": nr_ids, "action": "mark_processed",
|
|
506
|
+
"processed_batch": f"digest-noreason-{_now_stamp()}"})
|
|
507
|
+
except Exception as e:
|
|
508
|
+
log(f"project={name} no_reason_mark_processed_failed={e}")
|
|
509
|
+
log(f"project={name} platform={platform} ignored_no_reason={len(no_reason)} "
|
|
510
|
+
"(not processed into learnings)")
|
|
511
|
+
_drop = {id(e) for e in no_reason}
|
|
512
|
+
events = [e for e in events if id(e) not in _drop]
|
|
495
513
|
if not overall_events and not platform_events and not any(_is_actionable(e) for e in events):
|
|
496
514
|
log(f"project={name} platform={platform} events={len(events)} "
|
|
497
515
|
"plain_approvals_only, no digest (they ride along with the next actionable event)")
|
package/scripts/find_threads.py
CHANGED
|
@@ -29,14 +29,15 @@ except Exception:
|
|
|
29
29
|
def _resolve_account(_platform): # type: ignore[unused-arg]
|
|
30
30
|
return None
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
33
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
34
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
35
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
36
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
37
|
+
from config import config_path as _canonical_config_path, load_config
|
|
38
|
+
CONFIG_PATH = _canonical_config_path()
|
|
33
39
|
|
|
34
40
|
|
|
35
|
-
def load_config():
|
|
36
|
-
if os.path.exists(CONFIG_PATH):
|
|
37
|
-
with open(CONFIG_PATH) as f:
|
|
38
|
-
return json.load(f)
|
|
39
|
-
return {}
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
|
package/scripts/github_tools.py
CHANGED
|
@@ -36,12 +36,15 @@ def _github_account_filter():
|
|
|
36
36
|
return (" AND our_account = %s", [h])
|
|
37
37
|
return ("", [])
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
40
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
41
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
42
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
43
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
44
|
+
from config import config_path as _canonical_config_path, load_config as _load_config
|
|
45
|
+
CONFIG_PATH = _canonical_config_path()
|
|
40
46
|
|
|
41
47
|
|
|
42
|
-
def _load_config():
|
|
43
|
-
with open(CONFIG_PATH) as f:
|
|
44
|
-
return json.load(f)
|
|
45
48
|
|
|
46
49
|
|
|
47
50
|
def _excluded_repos_and_authors(config):
|
|
@@ -26,10 +26,13 @@ The feedback loop:
|
|
|
26
26
|
GLOBAL_LEARNED_PREFS_JSON (the 'Global learned preferences' line under
|
|
27
27
|
PROJECT ROUTING; per-project stamping retired 2026-07-14), so the block
|
|
28
28
|
(with its self-describing _instruction) reaches the judging/drafting
|
|
29
|
-
model automatically.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
model automatically. prompt_block() renders the same content for callers
|
|
30
|
+
that want an explicit section instead of the raw embed.
|
|
31
|
+
|
|
32
|
+
Audit history is NOT in the block: it lives in the sidecar JSONL at
|
|
33
|
+
history_path() (next to config.json), append-only, with no reader in any
|
|
34
|
+
prompt. Legacy in-config `history` is auto-migrated to the sidecar by the
|
|
35
|
+
write paths on the first mutation after upgrade, then dropped from config.
|
|
33
36
|
|
|
34
37
|
Block shape (config.json top-level key `learned_preferences_global`):
|
|
35
38
|
|
|
@@ -41,12 +44,12 @@ Block shape (config.json top-level key `learned_preferences_global`):
|
|
|
41
44
|
"thread_avoid": ["engagement-bait question threads ..."],
|
|
42
45
|
"draft_style_notes": [],
|
|
43
46
|
"edit_examples": [{"original", "final", "ts"}],
|
|
44
|
-
"updated_at": "..."
|
|
45
|
-
"history": [{"ts", "change", "rationale", "source_events": [ids]}]
|
|
47
|
+
"updated_at": "..."
|
|
46
48
|
}
|
|
49
|
+
# audit changelog -> sidecar learned_preferences_history.jsonl (not here)
|
|
47
50
|
|
|
48
51
|
WHITELIST: this module writes ONLY learned_preferences_global plus
|
|
49
|
-
(
|
|
52
|
+
(add + remove, still per-project) voice.never and content_guardrails.do_not.
|
|
50
53
|
Nothing else in a project entry is touchable through this path; unknown keys
|
|
51
54
|
in a mutation plan are dropped and counted, never applied. Facts
|
|
52
55
|
(content_angle, links, identity fields) are deliberately unreachable so a
|
|
@@ -65,10 +68,7 @@ from pathlib import Path
|
|
|
65
68
|
|
|
66
69
|
# Lists the digest may fully manage inside learned_preferences.
|
|
67
70
|
MANAGED_LISTS = ("audience_avoid", "audience_prefer", "thread_avoid", "draft_style_notes")
|
|
68
|
-
# Existing config fields the digest may APPEND to (never remove from).
|
|
69
|
-
APPEND_ONLY_FIELDS = ("voice_never_add", "guardrails_do_not_add")
|
|
70
71
|
|
|
71
|
-
MAX_HISTORY = 1000
|
|
72
72
|
|
|
73
73
|
# Edit examples: (original, final) pairs from the user's own card rewrites,
|
|
74
74
|
# recorded DETERMINISTICALLY by feedback_digest via record_edit_examples()
|
|
@@ -138,6 +138,31 @@ def _now_iso() -> str:
|
|
|
138
138
|
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
def history_path(cfg_path: str | None = None) -> str:
|
|
142
|
+
"""Sidecar audit log for learned_preferences mutations (2026-07-17). Lives
|
|
143
|
+
next to config.json as an append-only JSONL so the hot config file stays
|
|
144
|
+
lean; nothing reads it back into any prompt, it is purely a changelog."""
|
|
145
|
+
cfg_path = cfg_path or config_path()
|
|
146
|
+
return str(Path(cfg_path).with_name("learned_preferences_history.jsonl"))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _append_history(entries, cfg_path: str | None = None) -> None:
|
|
150
|
+
"""Append audit entries to the sidecar JSONL. Best-effort: never raises into
|
|
151
|
+
the caller (a failed audit write must never fail a real mutation). Callers
|
|
152
|
+
already hold the config flock, so appends are serialized."""
|
|
153
|
+
entries = [e for e in (entries or []) if isinstance(e, dict)]
|
|
154
|
+
if not entries:
|
|
155
|
+
return
|
|
156
|
+
hp = history_path(cfg_path)
|
|
157
|
+
try:
|
|
158
|
+
Path(hp).parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
with open(hp, "a") as f:
|
|
160
|
+
for e in entries:
|
|
161
|
+
f.write(json.dumps(e) + "\n")
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
141
166
|
def normalized(block) -> dict:
|
|
142
167
|
"""Coerce whatever is in config into the canonical block shape."""
|
|
143
168
|
b = block if isinstance(block, dict) else {}
|
|
@@ -165,8 +190,9 @@ def normalized(block) -> dict:
|
|
|
165
190
|
if isinstance(e, dict) and str(e.get("original") or "").strip() and str(e.get("final") or "").strip()
|
|
166
191
|
][:MAX_EDIT_EXAMPLES]
|
|
167
192
|
out["updated_at"] = b.get("updated_at")
|
|
168
|
-
|
|
169
|
-
|
|
193
|
+
# history no longer lives in the block: it moved to the sidecar JSONL
|
|
194
|
+
# (history_path). Any legacy history still in config is flushed to the
|
|
195
|
+
# sidecar by the write paths, then dropped here on the next normalized write.
|
|
170
196
|
return out
|
|
171
197
|
|
|
172
198
|
|
|
@@ -298,15 +324,18 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
|
|
|
298
324
|
return {"ok": True, "recorded": 0}
|
|
299
325
|
block["edit_examples"] = (fresh + existing)[:MAX_EDIT_EXAMPLES]
|
|
300
326
|
block["updated_at"] = _now_iso()
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
327
|
+
entry = {
|
|
328
|
+
"ts": _now_iso(),
|
|
329
|
+
"change": f"edit_examples recorded: {len(fresh)}",
|
|
330
|
+
"rationale": "user rewrote the draft on the review card",
|
|
331
|
+
"source_events": [],
|
|
332
|
+
"project": project_name,
|
|
333
|
+
}
|
|
334
|
+
# Flush any legacy in-config history to the sidecar once (pre-sidecar
|
|
335
|
+
# installs), then append this entry. `block` is normalized (no history
|
|
336
|
+
# key), so the write below drops history from config.json for good.
|
|
337
|
+
legacy = (cfg.get("learned_preferences_global") or {}).get("history")
|
|
338
|
+
_append_history((legacy or []) + [entry], cfg_path)
|
|
310
339
|
cfg["learned_preferences_global"] = block
|
|
311
340
|
|
|
312
341
|
stamp = _now_iso().replace(":", "-").replace(".", "-")
|
|
@@ -393,35 +422,48 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
|
|
|
393
422
|
applied.append(f"{key} added: {v}")
|
|
394
423
|
|
|
395
424
|
# Append-only extensions of existing curated fields.
|
|
396
|
-
for v in _validate_add_list(plan.get("voice_never_add")
|
|
425
|
+
for v in _validate_add_list(plan.get("voice_never_add")):
|
|
397
426
|
voice = proj.setdefault("voice", {})
|
|
398
427
|
never = voice.setdefault("never", [])
|
|
399
428
|
if isinstance(never, list) and v not in never:
|
|
400
429
|
never.append(v)
|
|
401
430
|
applied.append(f"voice.never added: {v}")
|
|
402
|
-
for v in _validate_add_list(plan.get("
|
|
431
|
+
for v in _validate_add_list(plan.get("voice_never_remove")):
|
|
432
|
+
never = (proj.get("voice") or {}).get("never")
|
|
433
|
+
if isinstance(never, list) and v in never:
|
|
434
|
+
never.remove(v)
|
|
435
|
+
applied.append(f"voice.never removed: {v}")
|
|
436
|
+
for v in _validate_add_list(plan.get("guardrails_do_not_add")):
|
|
403
437
|
guard = proj.setdefault("content_guardrails", {})
|
|
404
438
|
do_not = guard.setdefault("do_not", [])
|
|
405
439
|
if isinstance(do_not, list) and v not in do_not:
|
|
406
440
|
do_not.append(v)
|
|
407
441
|
applied.append(f"content_guardrails.do_not added: {v}")
|
|
442
|
+
for v in _validate_add_list(plan.get("guardrails_do_not_remove")):
|
|
443
|
+
do_not = (proj.get("content_guardrails") or {}).get("do_not")
|
|
444
|
+
if isinstance(do_not, list) and v in do_not:
|
|
445
|
+
do_not.remove(v)
|
|
446
|
+
applied.append(f"content_guardrails.do_not removed: {v}")
|
|
408
447
|
|
|
409
|
-
for key in set(plan.keys()) - {"changes", "voice_never_add", "guardrails_do_not_add", "rationale", "project"}:
|
|
448
|
+
for key in set(plan.keys()) - {"changes", "voice_never_add", "voice_never_remove", "guardrails_do_not_add", "guardrails_do_not_remove", "rationale", "project"}:
|
|
410
449
|
dropped.append(f"unknown top-level key '{key}'")
|
|
411
450
|
|
|
412
451
|
if not applied:
|
|
413
452
|
return {"ok": True, "applied": [], "dropped": dropped, "project_found": True}
|
|
414
453
|
|
|
415
454
|
block["updated_at"] = _now_iso()
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
455
|
+
entry = {
|
|
456
|
+
"ts": _now_iso(),
|
|
457
|
+
"change": "; ".join(applied)[:1000],
|
|
458
|
+
"rationale": rationale,
|
|
459
|
+
"source_events": list(source_event_ids or [])[:100],
|
|
460
|
+
"project": project_name,
|
|
461
|
+
}
|
|
462
|
+
# Flush any legacy in-config history to the sidecar once (pre-sidecar
|
|
463
|
+
# installs), then append this entry. `block` is normalized (no history
|
|
464
|
+
# key), so the write below drops history from config.json for good.
|
|
465
|
+
legacy = (cfg.get("learned_preferences_global") or {}).get("history")
|
|
466
|
+
_append_history((legacy or []) + [entry], cfg_path)
|
|
425
467
|
cfg["learned_preferences_global"] = block
|
|
426
468
|
|
|
427
469
|
# Backup + atomic replace (same shape as setup.ts applySetup).
|
|
@@ -503,7 +545,8 @@ def migrate_to_global(cfg_path: str | None = None) -> dict:
|
|
|
503
545
|
return {"ok": True, "migrated": False, "projects_merged": [], "excluded": [], "dropped_at_cap": []}
|
|
504
546
|
|
|
505
547
|
global_block = normalized(cfg.get("learned_preferences_global"))
|
|
506
|
-
|
|
548
|
+
# legacy history still in raw config gets flushed to the sidecar below.
|
|
549
|
+
merged_history = list((cfg.get("learned_preferences_global") or {}).get("history") or [])
|
|
507
550
|
excluded = []
|
|
508
551
|
dropped_at_cap = []
|
|
509
552
|
projects_merged = []
|
|
@@ -532,7 +575,7 @@ def migrate_to_global(cfg_path: str | None = None) -> dict:
|
|
|
532
575
|
global_block["edit_examples"].append(e)
|
|
533
576
|
existing_finals.add(e["final"])
|
|
534
577
|
touched = True
|
|
535
|
-
for h in
|
|
578
|
+
for h in (raw.get("history") or []) if isinstance(raw, dict) else []:
|
|
536
579
|
merged_history.append({**h, "project": pname})
|
|
537
580
|
touched = True
|
|
538
581
|
if touched:
|
|
@@ -556,7 +599,7 @@ def migrate_to_global(cfg_path: str | None = None) -> dict:
|
|
|
556
599
|
"excluded": excluded,
|
|
557
600
|
"dropped_at_cap": dropped_at_cap,
|
|
558
601
|
})
|
|
559
|
-
|
|
602
|
+
_append_history(merged_history, cfg_path)
|
|
560
603
|
global_block["updated_at"] = _now_iso()
|
|
561
604
|
cfg["learned_preferences_global"] = global_block
|
|
562
605
|
|
package/scripts/pick_project.py
CHANGED
|
@@ -25,15 +25,18 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
25
25
|
|
|
26
26
|
from project_topics import topics_for_project # noqa: E402
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
29
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
30
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
31
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
32
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
33
|
+
from config import config_path as _canonical_config_path, load_config
|
|
34
|
+
CONFIG_PATH = _canonical_config_path()
|
|
29
35
|
|
|
30
36
|
# Rolling window (days) for inverse-recent-share weighting in pick_projects().
|
|
31
37
|
RECENT_WINDOW_DAYS = 7
|
|
32
38
|
|
|
33
39
|
|
|
34
|
-
def load_config():
|
|
35
|
-
with open(CONFIG_PATH) as f:
|
|
36
|
-
return json.load(f)
|
|
37
40
|
|
|
38
41
|
|
|
39
42
|
def _counts_via_api(platform=None):
|
|
@@ -29,14 +29,17 @@ from datetime import datetime, timezone
|
|
|
29
29
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
30
30
|
from http_api import api_get
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
33
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
34
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
35
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
36
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
37
|
+
from config import config_path as _canonical_config_path, load_config
|
|
38
|
+
CONFIG_PATH = _canonical_config_path()
|
|
33
39
|
DEFAULT_OWN_FLOOR_DAYS = 1
|
|
34
40
|
DEFAULT_EXTERNAL_FLOOR_DAYS = 3
|
|
35
41
|
|
|
36
42
|
|
|
37
|
-
def load_config():
|
|
38
|
-
with open(CONFIG_PATH) as f:
|
|
39
|
-
return json.load(f)
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
def _parse_dt(s):
|
|
@@ -31,14 +31,17 @@ import sys
|
|
|
31
31
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
32
32
|
from http_api import api_get # noqa: E402
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
35
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
36
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
37
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
38
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
39
|
+
from config import config_path as _canonical_config_path, load_config
|
|
40
|
+
CONFIG_PATH = _canonical_config_path()
|
|
35
41
|
DEFAULT_TOPIC_FLOOR_DAYS = 2
|
|
36
42
|
TWITTER_DAILY_CAP = 3 # hard global cap. user requirement, do not raise without explicit ask.
|
|
37
43
|
|
|
38
44
|
|
|
39
|
-
def load_config():
|
|
40
|
-
with open(CONFIG_PATH) as f:
|
|
41
|
-
return json.load(f)
|
|
42
45
|
|
|
43
46
|
|
|
44
47
|
def _fetch_picker_context(angle_window_days=14, counts_window_days=7):
|
package/scripts/post_github.py
CHANGED
|
@@ -146,7 +146,13 @@ except Exception: # never let a missing module break the poster
|
|
|
146
146
|
|
|
147
147
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
148
148
|
SCRIPTS = os.path.join(REPO_DIR, "scripts")
|
|
149
|
-
|
|
149
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
150
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
151
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
152
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
153
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
154
|
+
from config import config_path as _canonical_config_path, load_config
|
|
155
|
+
CONFIG_PATH = _canonical_config_path()
|
|
150
156
|
SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
|
|
151
157
|
GITHUB_TOOLS = os.path.join(SCRIPTS, "github_tools.py")
|
|
152
158
|
RUN_CLAUDE = os.path.join(SCRIPTS, "run_claude.sh")
|
|
@@ -186,9 +192,6 @@ def log(msg):
|
|
|
186
192
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] [post_github] {msg}", flush=True)
|
|
187
193
|
|
|
188
194
|
|
|
189
|
-
def load_config():
|
|
190
|
-
with open(CONFIG_PATH) as f:
|
|
191
|
-
return json.load(f)
|
|
192
195
|
|
|
193
196
|
|
|
194
197
|
# ---------- Project picking & context ---------------------------------------
|
package/scripts/post_reddit.py
CHANGED
|
@@ -42,7 +42,13 @@ import draft_prompt_core as _dpc
|
|
|
42
42
|
# the package instead of a nonexistent ~/social-autoposter (the same $HOME
|
|
43
43
|
# hardcode class that silently no-op'd session restore on customer boxes).
|
|
44
44
|
REPO_DIR = os.path.expanduser(os.environ.get("S4L_REPO_DIR") or "~/social-autoposter")
|
|
45
|
-
|
|
45
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
46
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
47
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
48
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
49
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
50
|
+
from config import config_path as _canonical_config_path, load_config
|
|
51
|
+
CONFIG_PATH = _canonical_config_path()
|
|
46
52
|
REDDIT_BROWSER = os.path.join(REPO_DIR, "scripts", "reddit_browser.py")
|
|
47
53
|
REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
|
|
48
54
|
REDDIT_TOOLS = os.path.join(REPO_DIR, "scripts", "reddit_tools.py")
|
|
@@ -729,9 +735,6 @@ def mark_thread_blocked(subreddit: str, abort_reason: str = "",
|
|
|
729
735
|
print(f"[post_reddit] WARNING: could not persist thread-blocked sub r/{sub}: {e}")
|
|
730
736
|
|
|
731
737
|
|
|
732
|
-
def load_config():
|
|
733
|
-
with open(CONFIG_PATH) as f:
|
|
734
|
-
return json.load(f)
|
|
735
738
|
|
|
736
739
|
|
|
737
740
|
def pick_project(platform="reddit", exclude=None):
|
package/scripts/project_stats.py
CHANGED
|
@@ -22,7 +22,13 @@ from http_api import api_get # noqa: E402
|
|
|
22
22
|
from project_slugs import get_client_slug, get_booking_table # noqa: E402
|
|
23
23
|
|
|
24
24
|
ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
|
|
25
|
-
|
|
25
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
26
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
27
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
28
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
29
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
30
|
+
from config import config_path as _canonical_config_path, load_config
|
|
31
|
+
CONFIG_PATH = _canonical_config_path()
|
|
26
32
|
|
|
27
33
|
|
|
28
34
|
def load_env():
|
|
@@ -35,9 +41,6 @@ def load_env():
|
|
|
35
41
|
os.environ.setdefault(k.strip(), v.strip())
|
|
36
42
|
|
|
37
43
|
|
|
38
|
-
def load_config():
|
|
39
|
-
with open(CONFIG_PATH) as f:
|
|
40
|
-
return json.load(f)
|
|
41
44
|
|
|
42
45
|
|
|
43
46
|
def posthog_query(api_key, project_id, event, host_filter, after_date):
|
|
@@ -35,7 +35,13 @@ from project_topics import topics_for_project
|
|
|
35
35
|
|
|
36
36
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
37
37
|
SCRIPTS = os.path.join(REPO_DIR, "scripts")
|
|
38
|
-
|
|
38
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
39
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
40
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
41
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
42
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
43
|
+
from config import config_path as _canonical_config_path, load_config
|
|
44
|
+
CONFIG_PATH = _canonical_config_path()
|
|
39
45
|
SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
|
|
40
46
|
MOLTBOOK_POST = os.path.join(SCRIPTS, "moltbook_post.py")
|
|
41
47
|
RUN_CLAUDE = os.path.join(SCRIPTS, "run_claude.sh")
|
|
@@ -53,9 +59,6 @@ def log(msg):
|
|
|
53
59
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
54
60
|
|
|
55
61
|
|
|
56
|
-
def load_config():
|
|
57
|
-
with open(CONFIG_PATH) as f:
|
|
58
|
-
return json.load(f)
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
def api_key():
|
|
@@ -25,7 +25,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
25
25
|
import db as dbmod
|
|
26
26
|
from project_topics import topics_for_project
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
29
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
30
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
31
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
32
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
33
|
+
from config import config_path as _canonical_config_path, load_config
|
|
34
|
+
CONFIG_PATH = _canonical_config_path()
|
|
29
35
|
# Min-word floor to promote a public reply into a DM candidate.
|
|
30
36
|
# X replies are natively shorter (quote-tweets, @-mentions), so the bar is lower.
|
|
31
37
|
# Reddit floor lowered to 4 on 2026-04-21 after data showed 4-9 word Reddit
|
|
@@ -114,11 +120,6 @@ TRANSIENT_SKIP_REASON_PATTERNS = (
|
|
|
114
120
|
"%transient_browser_failure%",
|
|
115
121
|
)
|
|
116
122
|
|
|
117
|
-
def load_config():
|
|
118
|
-
if os.path.exists(CONFIG_PATH):
|
|
119
|
-
with open(CONFIG_PATH) as f:
|
|
120
|
-
return json.load(f)
|
|
121
|
-
return {}
|
|
122
123
|
|
|
123
124
|
|
|
124
125
|
def word_count(text):
|
|
@@ -18,7 +18,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
18
18
|
from http_api import api_get, api_post
|
|
19
19
|
|
|
20
20
|
MIN_WORDS = 5
|
|
21
|
-
|
|
21
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
22
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
23
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
24
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
25
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
26
|
+
from config import config_path as _canonical_config_path, load_config
|
|
27
|
+
CONFIG_PATH = _canonical_config_path()
|
|
22
28
|
|
|
23
29
|
# NOTE: posts/replies for GitHub live under platform='github' in the DB; the
|
|
24
30
|
# 'github_issues' value used here matches zero rows, so Phase A has long been a
|
|
@@ -29,11 +35,6 @@ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
|
29
35
|
SCAN_PLATFORM = "github_issues"
|
|
30
36
|
|
|
31
37
|
|
|
32
|
-
def load_config():
|
|
33
|
-
if os.path.exists(CONFIG_PATH):
|
|
34
|
-
with open(CONFIG_PATH) as f:
|
|
35
|
-
return json.load(f)
|
|
36
|
-
return {}
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def word_count(text):
|
|
@@ -35,7 +35,13 @@ from moltbook_tools import (
|
|
|
35
35
|
MoltbookRateLimitedError,
|
|
36
36
|
)
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
39
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
40
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
41
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
42
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
43
|
+
from config import config_path as _canonical_config_path, load_config
|
|
44
|
+
CONFIG_PATH = _canonical_config_path()
|
|
39
45
|
|
|
40
46
|
PAGE_LIMIT = 100
|
|
41
47
|
MAX_PAGES = 20 # caps pagination at ~2000 items per run
|
|
@@ -46,11 +52,6 @@ MIN_WORDS = 5
|
|
|
46
52
|
PAGE_PAUSE_SECONDS = 1.0
|
|
47
53
|
|
|
48
54
|
|
|
49
|
-
def load_config():
|
|
50
|
-
if os.path.exists(CONFIG_PATH):
|
|
51
|
-
with open(CONFIG_PATH) as f:
|
|
52
|
-
return json.load(f)
|
|
53
|
-
return {}
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
def word_count(text):
|
|
@@ -37,7 +37,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
37
37
|
from http_api import api_get
|
|
38
38
|
from reply_insert import insert_reply as _insert_reply
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
41
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
42
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
43
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
44
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
45
|
+
from config import config_path as _canonical_config_path, load_config
|
|
46
|
+
CONFIG_PATH = _canonical_config_path()
|
|
41
47
|
COOKIES_PATH = os.path.expanduser("~/.config/social-autoposter/reddit-cookies.json")
|
|
42
48
|
ENGAGE_SCRIPT = os.path.expanduser("~/social-autoposter/scripts/engage_reddit.py")
|
|
43
49
|
|
|
@@ -53,11 +59,6 @@ OWN_COMMENTS_LOOKBACK_DAYS = 30 # stop once we pass this many days back
|
|
|
53
59
|
THREAD_ID_RE = re.compile(r"/comments/([a-z0-9]+)/")
|
|
54
60
|
|
|
55
61
|
|
|
56
|
-
def load_config():
|
|
57
|
-
if os.path.exists(CONFIG_PATH):
|
|
58
|
-
with open(CONFIG_PATH) as f:
|
|
59
|
-
return json.load(f)
|
|
60
|
-
return {}
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
def load_cookies():
|
|
@@ -42,7 +42,13 @@ except Exception:
|
|
|
42
42
|
def _resolve_account(_platform): # type: ignore[unused-arg]
|
|
43
43
|
return None
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
46
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
47
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
48
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
49
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
50
|
+
from config import config_path as _canonical_config_path, load_config
|
|
51
|
+
CONFIG_PATH = _canonical_config_path()
|
|
46
52
|
MIN_WORDS = 3
|
|
47
53
|
OUR_HANDLE = _resolve_account("twitter")
|
|
48
54
|
if not OUR_HANDLE:
|
|
@@ -61,11 +67,6 @@ REPLY_PAGE_LIMIT = 500
|
|
|
61
67
|
REPLY_MAX_PAGES = 200 # 100k rows of headroom; plenty for the dedup cache.
|
|
62
68
|
|
|
63
69
|
|
|
64
|
-
def load_config():
|
|
65
|
-
if os.path.exists(CONFIG_PATH):
|
|
66
|
-
with open(CONFIG_PATH) as f:
|
|
67
|
-
return json.load(f)
|
|
68
|
-
return {}
|
|
69
70
|
|
|
70
71
|
|
|
71
72
|
def word_count(text):
|
|
@@ -50,7 +50,13 @@ except Exception:
|
|
|
50
50
|
def _resolve_account(_platform): # type: ignore[unused-arg]
|
|
51
51
|
return None
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
54
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
55
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
56
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
57
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
58
|
+
from config import config_path as _canonical_config_path, load_config
|
|
59
|
+
CONFIG_PATH = _canonical_config_path()
|
|
54
60
|
OUR_HANDLE = _resolve_account("twitter")
|
|
55
61
|
if not OUR_HANDLE:
|
|
56
62
|
# No hardcoded fallback: scanning/attributing under a default handle silently
|
|
@@ -72,11 +78,6 @@ REPLY_PAGE_LIMIT = 500
|
|
|
72
78
|
PYTHON = os.environ.get("S4L_PYTHON") or sys.executable
|
|
73
79
|
|
|
74
80
|
|
|
75
|
-
def load_config():
|
|
76
|
-
if os.path.exists(CONFIG_PATH):
|
|
77
|
-
with open(CONFIG_PATH) as f:
|
|
78
|
-
return json.load(f)
|
|
79
|
-
return {}
|
|
80
81
|
|
|
81
82
|
|
|
82
83
|
def fetch_our_recent_x_replies(days, max_urls):
|
package/scripts/stats.py
CHANGED
|
@@ -337,14 +337,15 @@ from moltbook_tools import (
|
|
|
337
337
|
MoltbookRateLimitedError,
|
|
338
338
|
)
|
|
339
339
|
|
|
340
|
-
|
|
340
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
341
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
342
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
343
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
344
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
345
|
+
from config import config_path as _canonical_config_path, load_config
|
|
346
|
+
CONFIG_PATH = _canonical_config_path()
|
|
341
347
|
|
|
342
348
|
|
|
343
|
-
def load_config():
|
|
344
|
-
if os.path.exists(CONFIG_PATH):
|
|
345
|
-
with open(CONFIG_PATH) as f:
|
|
346
|
-
return json.load(f)
|
|
347
|
-
return {}
|
|
348
349
|
|
|
349
350
|
|
|
350
351
|
class HttpNotFoundError(Exception):
|