@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.26",
3
- "installedAt": "2026-07-17T23:10:55.248Z"
2
+ "version": "1.7.6-rc.28",
3
+ "installedAt": "2026-07-17T23:30:24.991Z"
4
4
  }
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.26",
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.26",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.6-rc.26",
3
+ "version": "1.7.6-rc.28",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -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
- CONFIG_PATH = os.path.join(REPO_ROOT, "config.json")
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
- CONFIG_PATH = os.path.join(REPO_ROOT, "config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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):
@@ -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
- CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
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():
@@ -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
- CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
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(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved, {len(plat_removed)} platform moderation strikes):
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. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "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.
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 like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "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.
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. One removal is one community's judgment: treat it like a reasoned reject (it may justify one narrowly-scoped entry since the moderation context states the venue and outcome explicitly), and generalize only when 2+ removals share a pattern. 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) once 2+ bans or removals show it. Platform events never justify block_authors (there is no author at fault) and never touch audience_prefer.
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. The one exception is no_reason_given rejects, which stay weak (see below), because there the WHY is unknown, not because the count is low.
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, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
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)")
@@ -29,14 +29,15 @@ except Exception:
29
29
  def _resolve_account(_platform): # type: ignore[unused-arg]
30
30
  return None
31
31
 
32
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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"):
@@ -36,12 +36,15 @@ def _github_account_filter():
36
36
  return (" AND our_account = %s", [h])
37
37
  return ("", [])
38
38
 
39
- CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config.json")
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. `history` is stripped at prompt-build time: it is
30
- a config.json-only audit changelog with no reader in any prompt.
31
- prompt_block() renders the same content for callers that want an
32
- explicit section instead of the raw embed.
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
- (append-only, still per-project) voice.never and content_guardrails.do_not.
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
- hist = b.get("history")
169
- out["history"] = list(hist)[-MAX_HISTORY:] if isinstance(hist, list) else []
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
- block["history"] = (block["history"] + [
302
- {
303
- "ts": _now_iso(),
304
- "change": f"edit_examples recorded: {len(fresh)}",
305
- "rationale": "user rewrote the draft on the review card",
306
- "source_events": [],
307
- "project": project_name,
308
- }
309
- ])[-MAX_HISTORY:]
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"), cap=3):
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("guardrails_do_not_add"), cap=3):
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
- block["history"] = (block["history"] + [
417
- {
418
- "ts": _now_iso(),
419
- "change": "; ".join(applied)[:1000],
420
- "rationale": rationale,
421
- "source_events": list(source_event_ids or [])[:100],
422
- "project": project_name,
423
- }
424
- ])[-MAX_HISTORY:]
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
- merged_history = list(global_block["history"])
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 block["history"]:
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
- global_block["history"] = merged_history[-MAX_HISTORY:]
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
 
@@ -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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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):
@@ -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
- CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
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 ---------------------------------------
@@ -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
- CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
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):
@@ -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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
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):