@m13v/s4l 1.7.5-rc.2 → 1.7.5-rc.21
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/index.js +571 -79
- package/mcp/dist/panel.html +2 -3
- package/mcp/dist/redditAuth.js +115 -0
- package/mcp/dist/runtime.js +83 -2
- package/mcp/dist/setup.js +142 -5
- package/mcp/dist/telemetry.js +13 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +32 -6
- package/mcp/menubar/s4l_menubar.py +42 -111
- package/mcp/menubar/s4l_state.py +54 -187
- package/mcp/package.json +1 -1
- package/mcp/shared/onboarding-ledger.cjs +63 -4
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +3 -6
- package/scripts/browser_mutex.py +316 -0
- package/scripts/claude_job.py +21 -0
- package/scripts/config.py +118 -0
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engage_github.py +7 -3
- package/scripts/engage_reddit.py +9 -3
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/learned_preferences.py +37 -14
- package/scripts/memory_snapshot.py +45 -0
- package/scripts/merge_review_queue.py +67 -3
- package/scripts/post_reddit.py +289 -24
- package/scripts/reddit_browser.py +62 -63
- package/scripts/reddit_browser_fetch.py +32 -0
- package/scripts/release-mcpb.sh +17 -0
- package/scripts/reset-test-machine.sh +13 -7
- package/scripts/scan_twitter_thread_followups.py +7 -1
- package/scripts/schedule_state.py +1 -1
- package/scripts/setup_reddit_auth.py +925 -0
- package/scripts/snapshot.py +96 -4
- package/scripts/stats.py +37 -13
- package/scripts/test_no_silent_fallbacks.py +281 -0
- package/scripts/trigger_promo_lane_test.sh +91 -0
- package/scripts/twitter_browser.py +183 -342
- package/scripts/twitter_scan.py +75 -10
- package/scripts/watchdog_hung_runs.py +15 -9
- package/skill/lib/harness-common.sh +344 -0
- package/skill/lib/linkedin-backend.sh +61 -161
- package/skill/lib/reddit-backend.sh +39 -125
- package/skill/lib/twitter-backend.sh +51 -273
- package/skill/lock.sh +57 -0
- package/skill/run-reddit-search-launchd.sh +4 -1
- package/skill/run-reddit-search.sh +51 -2
- package/skill/run-twitter-cycle.sh +44 -21
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""browser_mutex.py — THE per-platform browser-session mutex (2026-07-14).
|
|
3
|
+
|
|
4
|
+
A faithful, parameterized extraction of scripts/twitter_browser.py's
|
|
5
|
+
battle-hardened file mutex, so every platform driver shares ONE implementation
|
|
6
|
+
instead of three divergent ones (reddit_browser.py's old copy still had the
|
|
7
|
+
check-then-write claim race AND the dead-holder starvation twitter fixed on
|
|
8
|
+
2026-06-16). The twitter semantics are preserved bit-for-bit: same lockfile
|
|
9
|
+
JSON shape ({session_id, timestamp, role}), same reclaim ladder, same
|
|
10
|
+
"locked by session" give-up string downstream parsers grep for.
|
|
11
|
+
|
|
12
|
+
This mutex serializes the PYTHON drivers of one harness Chrome. It deliberately
|
|
13
|
+
does NOT share a path with the shell pipeline lock (skill/lock.sh's
|
|
14
|
+
/tmp/social-autoposter-<name>.lock dirs): pipelines hold that lock around whole
|
|
15
|
+
phases while their child python ops take THIS one per op — merging the two onto
|
|
16
|
+
one path would deadlock that nesting (or need ancestor-walk inheritance in
|
|
17
|
+
every acquire). Unification here means one LIBRARY, not one lock.
|
|
18
|
+
|
|
19
|
+
Reclaim ladder (a holder we can PROVE dead is taken immediately, so a crashed
|
|
20
|
+
peer can never starve the fleet):
|
|
21
|
+
1. holder == us -> re-entrant; refresh and proceed.
|
|
22
|
+
1b. holder == $S4L_LOCK_OWNER (live) -> batch inherit: a poster parent holds
|
|
23
|
+
the lock across a whole approved batch; its child reply subprocesses
|
|
24
|
+
refresh instead of contending, and leave release to the parent.
|
|
25
|
+
2. UUID holder, pid gone -> stale legacy (Claude session) lock, reclaim.
|
|
26
|
+
3. python:PID, pid gone -> dead peer, reclaim.
|
|
27
|
+
4. age >= expiry -> failsafe (role "post" holders get the shorter
|
|
28
|
+
post_lock_expiry so a hung poster self-clears).
|
|
29
|
+
5. live UUID holder -> inherit (parent Claude session).
|
|
30
|
+
5b. we are role "post", holder is a LIVE lower-priority python peer ->
|
|
31
|
+
PREEMPT it (SIGTERM, grace, SIGKILL) and claim: posting is the scarce
|
|
32
|
+
user-initiated action; the aborted scan re-runs next tick.
|
|
33
|
+
6. live python peer -> wait, then give up after wait_max with the
|
|
34
|
+
structured "locked by session" error.
|
|
35
|
+
|
|
36
|
+
Do NOT "simplify" by letting shell pipelines rm -f the lockfile: that blind rm
|
|
37
|
+
deleted LIVE peers' locks (defect b, removed 2026-06-16). See
|
|
38
|
+
docs/twitter_browser_lock.md for the incident history behind every branch.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import signal
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
import time
|
|
50
|
+
|
|
51
|
+
_UUID_RE = re.compile(
|
|
52
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BrowserMutex:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
lock_file: str,
|
|
60
|
+
label: str,
|
|
61
|
+
lock_expiry: int = 300,
|
|
62
|
+
post_lock_expiry: int = 180,
|
|
63
|
+
wait_max: int = 45,
|
|
64
|
+
poll_interval: int = 2,
|
|
65
|
+
preempt_kill_wait: int = 5,
|
|
66
|
+
role: str | None = None,
|
|
67
|
+
on_touch=None,
|
|
68
|
+
):
|
|
69
|
+
"""label is the human prefix in error strings ("Twitter browser",
|
|
70
|
+
"Reddit browser") — downstream parsers grep "locked by session", keep
|
|
71
|
+
the shape. on_touch (optional callable) runs after every successful
|
|
72
|
+
acquire/refresh (reddit bumps its bash lease there); it must never
|
|
73
|
+
raise consequences: exceptions are swallowed."""
|
|
74
|
+
self.lock_file = os.path.expanduser(lock_file)
|
|
75
|
+
self.label = label
|
|
76
|
+
self.lock_expiry = lock_expiry
|
|
77
|
+
self.post_lock_expiry = post_lock_expiry
|
|
78
|
+
self.wait_max = wait_max
|
|
79
|
+
self.poll_interval = poll_interval
|
|
80
|
+
self.preempt_kill_wait = preempt_kill_wait
|
|
81
|
+
self.role = (role or os.environ.get("S4L_LOCK_ROLE") or "scan").strip() or "scan"
|
|
82
|
+
self.on_touch = on_touch
|
|
83
|
+
self.session_id = f"python:{os.getpid()}"
|
|
84
|
+
self.inherited = False
|
|
85
|
+
self.acquired_at: float | None = None
|
|
86
|
+
|
|
87
|
+
# ---- liveness probes ----------------------------------------------------
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _is_uuid_holder_alive(holder: str) -> bool:
|
|
90
|
+
if not holder:
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
return (
|
|
94
|
+
subprocess.run(
|
|
95
|
+
["pgrep", "-f", f"claude.*--session-id {holder}"],
|
|
96
|
+
stdout=subprocess.DEVNULL,
|
|
97
|
+
stderr=subprocess.DEVNULL,
|
|
98
|
+
timeout=2,
|
|
99
|
+
).returncode
|
|
100
|
+
== 0
|
|
101
|
+
)
|
|
102
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
103
|
+
return True # err on the side of NOT stealing
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _is_python_holder_alive(holder: str) -> bool:
|
|
107
|
+
if not holder.startswith("python:"):
|
|
108
|
+
return True # not a python holder; this probe makes no claim
|
|
109
|
+
try:
|
|
110
|
+
pid = int(holder.split(":", 1)[1])
|
|
111
|
+
except (ValueError, IndexError):
|
|
112
|
+
return True
|
|
113
|
+
try:
|
|
114
|
+
os.kill(pid, 0)
|
|
115
|
+
return True
|
|
116
|
+
except ProcessLookupError:
|
|
117
|
+
return False
|
|
118
|
+
except PermissionError:
|
|
119
|
+
return True
|
|
120
|
+
except OSError:
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
# ---- claim / preempt ----------------------------------------------------
|
|
124
|
+
def _try_take(self) -> bool:
|
|
125
|
+
"""O_CREAT|O_EXCL makes "is it free? then take it" one syscall, so two
|
|
126
|
+
cold-start acquirers can't both win (defect c)."""
|
|
127
|
+
try:
|
|
128
|
+
fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
129
|
+
except FileExistsError:
|
|
130
|
+
return False
|
|
131
|
+
except OSError:
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
os.write(fd, json.dumps(
|
|
135
|
+
{"session_id": self.session_id, "timestamp": int(time.time()), "role": self.role}
|
|
136
|
+
).encode())
|
|
137
|
+
finally:
|
|
138
|
+
os.close(fd)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
def _preempt(self, pid: int) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
os.kill(pid, signal.SIGTERM)
|
|
144
|
+
except ProcessLookupError:
|
|
145
|
+
return True
|
|
146
|
+
except OSError:
|
|
147
|
+
return False
|
|
148
|
+
deadline = time.time() + self.preempt_kill_wait
|
|
149
|
+
while time.time() < deadline:
|
|
150
|
+
try:
|
|
151
|
+
os.kill(pid, 0)
|
|
152
|
+
except OSError:
|
|
153
|
+
return True
|
|
154
|
+
time.sleep(0.2)
|
|
155
|
+
try:
|
|
156
|
+
os.kill(pid, signal.SIGKILL)
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
try:
|
|
160
|
+
os.kill(pid, 0)
|
|
161
|
+
except OSError:
|
|
162
|
+
return True
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def _touch(self):
|
|
166
|
+
if self.on_touch:
|
|
167
|
+
try:
|
|
168
|
+
self.on_touch()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
# ---- public API -----------------------------------------------------------
|
|
173
|
+
def acquire(self):
|
|
174
|
+
deadline = time.time() + self.wait_max
|
|
175
|
+
try:
|
|
176
|
+
os.makedirs(os.path.dirname(self.lock_file), exist_ok=True)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
while True:
|
|
180
|
+
if not os.path.exists(self.lock_file):
|
|
181
|
+
if self._try_take():
|
|
182
|
+
break
|
|
183
|
+
if time.time() >= deadline:
|
|
184
|
+
print(json.dumps({
|
|
185
|
+
"success": False,
|
|
186
|
+
"error": f"{self.label} lock contended on create; waited {self.wait_max}s, giving up."
|
|
187
|
+
}))
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
time.sleep(self.poll_interval)
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
with open(self.lock_file) as f:
|
|
193
|
+
lock = json.load(f)
|
|
194
|
+
except (json.JSONDecodeError, OSError):
|
|
195
|
+
if self._try_take():
|
|
196
|
+
break
|
|
197
|
+
if time.time() >= deadline:
|
|
198
|
+
print(json.dumps({
|
|
199
|
+
"success": False,
|
|
200
|
+
"error": f"{self.label} lock unreadable; waited {self.wait_max}s, giving up."
|
|
201
|
+
}))
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
time.sleep(self.poll_interval)
|
|
204
|
+
continue
|
|
205
|
+
age = time.time() - lock.get("timestamp", 0)
|
|
206
|
+
holder = lock.get("session_id", "")
|
|
207
|
+
holder_role = lock.get("role", "scan") # legacy locks (no role) = preemptable
|
|
208
|
+
|
|
209
|
+
# 1. Re-entrant.
|
|
210
|
+
if holder == self.session_id and not self.inherited:
|
|
211
|
+
self.refresh()
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
# 1b. Batch-owner inherit (posting).
|
|
215
|
+
batch_owner = os.environ.get("S4L_LOCK_OWNER") or ""
|
|
216
|
+
if holder and holder == batch_owner and self._is_python_holder_alive(holder):
|
|
217
|
+
self.session_id = holder
|
|
218
|
+
self.inherited = True
|
|
219
|
+
self.refresh()
|
|
220
|
+
print(f"[browser_lock] inherited batch owner={holder} "
|
|
221
|
+
f"role={holder_role} -> pid={os.getpid()}", file=sys.stderr)
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
# 2-4. Reclaim provably dead/expired holders.
|
|
225
|
+
reclaim_reason = ""
|
|
226
|
+
if _UUID_RE.match(holder or "") and not self._is_uuid_holder_alive(holder):
|
|
227
|
+
reclaim_reason = "dead_uuid"
|
|
228
|
+
elif holder.startswith("python:") and not self._is_python_holder_alive(holder):
|
|
229
|
+
reclaim_reason = "dead_python"
|
|
230
|
+
elif age >= (self.post_lock_expiry if holder_role == "post" else self.lock_expiry):
|
|
231
|
+
reclaim_reason = "expired"
|
|
232
|
+
if reclaim_reason:
|
|
233
|
+
try:
|
|
234
|
+
os.remove(self.lock_file)
|
|
235
|
+
except OSError:
|
|
236
|
+
pass
|
|
237
|
+
if self._try_take():
|
|
238
|
+
print(f"[browser_lock] reclaimed holder={holder or '<none>'} "
|
|
239
|
+
f"reason={reclaim_reason} age={int(age)}s -> pid={os.getpid()}",
|
|
240
|
+
file=sys.stderr)
|
|
241
|
+
break
|
|
242
|
+
time.sleep(self.poll_interval)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# 5. Live UUID holder = parent Claude session -> inherit.
|
|
246
|
+
if _UUID_RE.match(holder or ""):
|
|
247
|
+
self.session_id = holder
|
|
248
|
+
self.inherited = True
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
# 5b. POSTING PRIORITY: preempt a live lower-priority python peer.
|
|
252
|
+
if (
|
|
253
|
+
self.role == "post"
|
|
254
|
+
and holder.startswith("python:")
|
|
255
|
+
and holder_role != "post"
|
|
256
|
+
and self._is_python_holder_alive(holder)
|
|
257
|
+
):
|
|
258
|
+
try:
|
|
259
|
+
victim_pid = int(holder.split(":", 1)[1])
|
|
260
|
+
except (ValueError, IndexError):
|
|
261
|
+
victim_pid = 0
|
|
262
|
+
if victim_pid and self._preempt(victim_pid):
|
|
263
|
+
try:
|
|
264
|
+
os.remove(self.lock_file)
|
|
265
|
+
except OSError:
|
|
266
|
+
pass
|
|
267
|
+
if self._try_take():
|
|
268
|
+
print(
|
|
269
|
+
f"[browser_lock] post preempted holder={holder} "
|
|
270
|
+
f"role={holder_role} age={int(age)}s -> pid={os.getpid()}",
|
|
271
|
+
file=sys.stderr,
|
|
272
|
+
)
|
|
273
|
+
break
|
|
274
|
+
time.sleep(self.poll_interval)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# 6. Live python peer: wait, then give up (real contention).
|
|
278
|
+
if time.time() >= deadline:
|
|
279
|
+
print(json.dumps({
|
|
280
|
+
"success": False,
|
|
281
|
+
"error": f"{self.label} locked by session {holder} ({int(age)}s, peer alive); waited {self.wait_max}s, giving up."
|
|
282
|
+
}))
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
time.sleep(self.poll_interval)
|
|
285
|
+
continue
|
|
286
|
+
self.acquired_at = time.time()
|
|
287
|
+
self._touch()
|
|
288
|
+
|
|
289
|
+
def refresh(self):
|
|
290
|
+
try:
|
|
291
|
+
with open(self.lock_file, "w") as f:
|
|
292
|
+
json.dump({"session_id": self.session_id, "timestamp": int(time.time()), "role": self.role}, f)
|
|
293
|
+
except OSError:
|
|
294
|
+
pass
|
|
295
|
+
self._touch()
|
|
296
|
+
|
|
297
|
+
def release(self):
|
|
298
|
+
"""Inherited locks are the PARENT'S to release; never clobber them."""
|
|
299
|
+
if self.inherited:
|
|
300
|
+
return
|
|
301
|
+
try:
|
|
302
|
+
if os.path.exists(self.lock_file):
|
|
303
|
+
with open(self.lock_file) as f:
|
|
304
|
+
lock = json.load(f)
|
|
305
|
+
if lock.get("session_id") == self.session_id:
|
|
306
|
+
os.remove(self.lock_file)
|
|
307
|
+
if self.acquired_at:
|
|
308
|
+
held = time.time() - self.acquired_at
|
|
309
|
+
if held >= 5:
|
|
310
|
+
print(
|
|
311
|
+
f"[browser-lock] held {held:.0f}s "
|
|
312
|
+
f"(role={self.role}, pid={os.getpid()})",
|
|
313
|
+
file=sys.stderr,
|
|
314
|
+
)
|
|
315
|
+
except (json.JSONDecodeError, OSError):
|
|
316
|
+
pass
|
package/scripts/claude_job.py
CHANGED
|
@@ -71,6 +71,11 @@ TAG_TO_TYPE = {
|
|
|
71
71
|
# twitter_gen_links.py's Phase 2b-gen step, which already tolerates the
|
|
72
72
|
# queue worker's cadence — see scripts/link_tail.py).
|
|
73
73
|
"twitter-link-tail": "twitter-link-tail",
|
|
74
|
+
# Reddit draft phase (queue-native since 2026-07-14): post_reddit.py's
|
|
75
|
+
# draft turn became pure text->JSON (thread content is pre-fetched in
|
|
76
|
+
# Python and inlined; no Bash tools), mirroring the twitter Phase 2b
|
|
77
|
+
# tool-free conversion, so it rides the same universal worker.
|
|
78
|
+
"post-reddit-draft": "reddit-draft",
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
# queue type -> (activity state, label) the menu bar shows while the job is in
|
|
@@ -83,6 +88,10 @@ TYPE_TO_ACTIVITY = {
|
|
|
83
88
|
"invent-topic": ("learning", "new topic"),
|
|
84
89
|
"invent-queries": ("learning", "new queries"),
|
|
85
90
|
"twitter-link-tail": ("drafting", "link bridge"),
|
|
91
|
+
# Same bare "draft" label as twitter-prep ON PURPOSE: the menu bar title
|
|
92
|
+
# must stay concise (user rule 2026-07-14); the platform shows on the
|
|
93
|
+
# review card itself, not in the tray.
|
|
94
|
+
"reddit-draft": ("drafting", "draft"),
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
# queue type -> execution notes PREPENDED to the prompt sidecar at claim time.
|
|
@@ -114,6 +123,18 @@ TYPE_TO_WORKER_NOTES = {
|
|
|
114
123
|
"and not wrapped in any other keys. The caller unwraps a plain string "
|
|
115
124
|
"result directly; wrapping it in an object will corrupt the reply."
|
|
116
125
|
),
|
|
126
|
+
"reddit-draft": (
|
|
127
|
+
"WORKER EXECUTION NOTES (queue metadata; follow while executing the "
|
|
128
|
+
"prompt below): every Reddit thread's content is already inlined in "
|
|
129
|
+
"the prompt — do NOT fetch anything, do NOT run searches, no tools "
|
|
130
|
+
"are needed. Apply the prompt's SELECTION GATE to each candidate, "
|
|
131
|
+
"draft comments for the ones that pass, and submit ONE result object "
|
|
132
|
+
"matching the schema: {\"posts\": [...], \"rejects\": [...]}. A "
|
|
133
|
+
"candidate that fails the gate is simply absent from posts (add a "
|
|
134
|
+
"rejects entry only for the structural false-positive cases the "
|
|
135
|
+
"prompt describes). Batches are small; draft them in one pass and "
|
|
136
|
+
"submit promptly."
|
|
137
|
+
),
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""config.py — THE config.json loader. One repo-root resolution, one parser.
|
|
3
|
+
|
|
4
|
+
Before this module (2026-07-14), ~120 call sites each rolled their own
|
|
5
|
+
`json.load(open(.../config.json))` with at least nine different repo-root
|
|
6
|
+
variable conventions (REPO_DIR, REPO_ROOT, ROOT, _REPO_ROOT, ...), and most
|
|
7
|
+
hardcoded `~/social-autoposter`. That hardcode class runs code OUTSIDE the
|
|
8
|
+
managed package on customer boxes (unreachable by auto-update) and silently
|
|
9
|
+
no-ops where the directory doesn't exist — the exact bug that broke session
|
|
10
|
+
restore and tab cleanup on customer installs (S4L-4H triage 2026-07-12).
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from config import repo_dir, config_path, load_config, get
|
|
14
|
+
|
|
15
|
+
cfg = load_config() # dict, cached
|
|
16
|
+
projects = get("projects", []) # top-level key
|
|
17
|
+
handle = get("accounts.twitter.handle") # dotted path
|
|
18
|
+
cfg = load_config(fresh=True) # bypass cache (long-lived procs)
|
|
19
|
+
|
|
20
|
+
Resolution order for the repo root (single source of truth):
|
|
21
|
+
1. $S4L_REPO_DIR — set by managed-install launchd plists and the MCP
|
|
22
|
+
server, points at ~/.social-autoposter-mcp/repo/package
|
|
23
|
+
2. this file's parent — scripts/ lives one level under the repo root, so a
|
|
24
|
+
direct checkout resolves to itself without any env
|
|
25
|
+
3. ~/social-autoposter — legacy operator-box fallback
|
|
26
|
+
|
|
27
|
+
$S4L_CONFIG_PATH overrides the config file location outright (some installs
|
|
28
|
+
pin the operator config, e.g. ~/s4l/config.json, independent of the code dir).
|
|
29
|
+
|
|
30
|
+
Do NOT add write helpers here. Config writes go through the MCP server's
|
|
31
|
+
project_config tool (it owns validation and the state snapshot); pipeline
|
|
32
|
+
scripts are readers.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
|
|
40
|
+
_CACHE: dict | None = None
|
|
41
|
+
_CACHE_PATH: str | None = None
|
|
42
|
+
_CACHE_MTIME: float | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def repo_dir() -> str:
|
|
46
|
+
env = os.environ.get("S4L_REPO_DIR")
|
|
47
|
+
if env:
|
|
48
|
+
return os.path.expanduser(env)
|
|
49
|
+
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
50
|
+
if os.path.exists(os.path.join(here, "config.json")):
|
|
51
|
+
return here
|
|
52
|
+
return os.path.expanduser("~/social-autoposter")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def config_path() -> str:
|
|
56
|
+
env = os.environ.get("S4L_CONFIG_PATH")
|
|
57
|
+
if env:
|
|
58
|
+
p = os.path.expanduser(env)
|
|
59
|
+
else:
|
|
60
|
+
sp = state_config_path()
|
|
61
|
+
p = sp if os.path.exists(sp) else os.path.join(repo_dir(), "config.json")
|
|
62
|
+
# Resolve symlinks to the real target. os.replace()-style atomic writers
|
|
63
|
+
# otherwise REPLACE a symlink with a plain file instead of writing through
|
|
64
|
+
# it, which is exactly how the operator Mac's config split in two on
|
|
65
|
+
# 2026-07-11 and again on 2026-07-13. realpath of a plain file is itself,
|
|
66
|
+
# so customer installs are unaffected.
|
|
67
|
+
return os.path.realpath(p) if os.path.exists(p) else p
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def state_config_path() -> str:
|
|
71
|
+
"""Canonical home of config.json (2026-07-13): the state dir, which no
|
|
72
|
+
package update, re-materialize, or plist regeneration ever touches. The
|
|
73
|
+
MCP server migrates a legacy $S4L_REPO_DIR/config.json here on boot and
|
|
74
|
+
leaves a symlink behind for direct-path readers; config_path() prefers
|
|
75
|
+
this file whenever it exists."""
|
|
76
|
+
state_dir = os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp"
|
|
77
|
+
return os.path.join(os.path.expanduser(state_dir), "config.json")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_config(fresh: bool = False) -> dict:
|
|
81
|
+
"""Parsed config.json. Cached per (path, mtime); pass fresh=True to force
|
|
82
|
+
a re-read regardless (e.g. long-lived processes reacting to edits)."""
|
|
83
|
+
global _CACHE, _CACHE_PATH, _CACHE_MTIME
|
|
84
|
+
path = config_path()
|
|
85
|
+
try:
|
|
86
|
+
mtime = os.path.getmtime(path)
|
|
87
|
+
except OSError:
|
|
88
|
+
return {}
|
|
89
|
+
if not fresh and _CACHE is not None and _CACHE_PATH == path and _CACHE_MTIME == mtime:
|
|
90
|
+
return _CACHE
|
|
91
|
+
try:
|
|
92
|
+
with open(path) as f:
|
|
93
|
+
_CACHE = json.load(f)
|
|
94
|
+
except (OSError, json.JSONDecodeError):
|
|
95
|
+
return _CACHE if (_CACHE is not None and _CACHE_PATH == path) else {}
|
|
96
|
+
_CACHE_PATH = path
|
|
97
|
+
_CACHE_MTIME = mtime
|
|
98
|
+
return _CACHE
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get(path: str, default=None):
|
|
102
|
+
"""Dotted-path lookup into config.json: get("accounts.twitter.handle").
|
|
103
|
+
A single-segment path reads a top-level key. Lists are not traversed;
|
|
104
|
+
fetch the list and iterate at the call site."""
|
|
105
|
+
node = load_config()
|
|
106
|
+
for part in path.split("."):
|
|
107
|
+
if not isinstance(node, dict) or part not in node:
|
|
108
|
+
return default
|
|
109
|
+
node = node[part]
|
|
110
|
+
return node
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def project(name: str) -> dict | None:
|
|
114
|
+
"""The projects[] entry with this name (case-insensitive), else None."""
|
|
115
|
+
for p in load_config().get("projects") or []:
|
|
116
|
+
if isinstance(p, dict) and str(p.get("name", "")).lower() == name.lower():
|
|
117
|
+
return p
|
|
118
|
+
return None
|
|
@@ -83,10 +83,25 @@ def _conversation_root(platform, post_id, their_comment_url):
|
|
|
83
83
|
return f"url:{their_comment_url.rsplit('/', 1)[0]}"
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
# Same-author reply cap advisory (2026-07-14): a run of near-identical
|
|
87
|
+
# corrections to one person over a short window reads as one-note bot
|
|
88
|
+
# behavior even when each individual reply was earned. This is a SOFT
|
|
89
|
+
# nudge appended to the history block, not a hard skip, since a genuine
|
|
90
|
+
# direct question from the author (see get_counterparty_history_block)
|
|
91
|
+
# still earns a reply past the threshold; only a deterministic count-based
|
|
92
|
+
# gate would wrongly suppress that case.
|
|
93
|
+
RECENT_CAP_WINDOW_HOURS = 48
|
|
94
|
+
RECENT_CAP_THRESHOLD = 3
|
|
95
|
+
|
|
96
|
+
|
|
86
97
|
def _fetch_author_summary(platform, author, days=7):
|
|
87
98
|
"""Compute bot/loop-judgment stats for `author` in the last `days` window.
|
|
88
99
|
|
|
89
|
-
Returns
|
|
100
|
+
Returns (summary_line, recent_replied_count). summary_line is "" when
|
|
101
|
+
there is no history. recent_replied_count is how many of our replies to
|
|
102
|
+
this author landed inside RECENT_CAP_WINDOW_HOURS, used for the same-
|
|
103
|
+
author cap advisory below.
|
|
104
|
+
|
|
90
105
|
Signals: total candidates, our replied count, our skipped count,
|
|
91
106
|
distinct conversation roots, our_replies / distinct_roots ratio (the
|
|
92
107
|
"engagement-loop shape" metric — closer to 1.0 = farm-shaped), skip
|
|
@@ -94,7 +109,7 @@ def _fetch_author_summary(platform, author, days=7):
|
|
|
94
109
|
clean), span_hours.
|
|
95
110
|
"""
|
|
96
111
|
if not author:
|
|
97
|
-
return ""
|
|
112
|
+
return "", 0
|
|
98
113
|
since_ts = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
|
|
99
114
|
try:
|
|
100
115
|
resp = api_get(
|
|
@@ -114,11 +129,11 @@ def _fetch_author_summary(platform, author, days=7):
|
|
|
114
129
|
f"{platform}/@{author}: {e}",
|
|
115
130
|
file=sys.stderr,
|
|
116
131
|
)
|
|
117
|
-
return ""
|
|
132
|
+
return "", 0
|
|
118
133
|
|
|
119
134
|
total = len(rows)
|
|
120
135
|
if total == 0:
|
|
121
|
-
return ""
|
|
136
|
+
return "", 0
|
|
122
137
|
|
|
123
138
|
replied = sum(1 for r in rows if r.get("status") == "replied")
|
|
124
139
|
skipped = sum(1 for r in rows if r.get("status") == "skipped")
|
|
@@ -142,13 +157,29 @@ def _fetch_author_summary(platform, author, days=7):
|
|
|
142
157
|
if len(timestamps) >= 2:
|
|
143
158
|
span_h = (max(timestamps) - min(timestamps)).total_seconds() / 3600.0
|
|
144
159
|
|
|
145
|
-
|
|
160
|
+
recent_cutoff = datetime.now(timezone.utc) - timedelta(hours=RECENT_CAP_WINDOW_HOURS)
|
|
161
|
+
recent_replied = 0
|
|
162
|
+
for r in rows:
|
|
163
|
+
if r.get("status") != "replied":
|
|
164
|
+
continue
|
|
165
|
+
ts = r.get("replied_at") or r.get("discovered_at")
|
|
166
|
+
if not ts:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
if dt >= recent_cutoff:
|
|
173
|
+
recent_replied += 1
|
|
174
|
+
|
|
175
|
+
summary_line = (
|
|
146
176
|
f"SUMMARY (last {days}d): {total} candidates, {replied} our_replies, "
|
|
147
177
|
f"{skipped} skipped ({skip_pct:.1f}% skip_rate), "
|
|
148
178
|
f"{distinct_roots} distinct conversation_roots "
|
|
149
179
|
f"(replies/root={ratio:.2f}, closer to 1.0 = farm-shaped), "
|
|
150
180
|
f"span={span_h:.1f}h"
|
|
151
181
|
)
|
|
182
|
+
return summary_line, recent_replied
|
|
152
183
|
|
|
153
184
|
|
|
154
185
|
def _fetch_dm_history(platform, author, post_id):
|
|
@@ -302,7 +333,7 @@ def get_counterparty_history_block(platform, author, current_post_id=None, curre
|
|
|
302
333
|
sum_fut = ex.submit(_fetch_author_summary, platform, author, 7)
|
|
303
334
|
same_post_disengage, dm_lines = dm_fut.result()
|
|
304
335
|
pub_lines = pub_fut.result()
|
|
305
|
-
summary_line = sum_fut.result()
|
|
336
|
+
summary_line, recent_replied = sum_fut.result()
|
|
306
337
|
|
|
307
338
|
if not dm_lines and not pub_lines and not summary_line:
|
|
308
339
|
return same_post_disengage, ""
|
|
@@ -317,6 +348,13 @@ def get_counterparty_history_block(platform, author, current_post_id=None, curre
|
|
|
317
348
|
if summary_line:
|
|
318
349
|
parts.append("")
|
|
319
350
|
parts.append(summary_line)
|
|
351
|
+
if recent_replied >= RECENT_CAP_THRESHOLD:
|
|
352
|
+
parts.append(
|
|
353
|
+
f"\nCAUTION: we've already replied to @{author} {recent_replied} times in the "
|
|
354
|
+
f"last {RECENT_CAP_WINDOW_HOURS}h. Repeating the same correction again reads as "
|
|
355
|
+
"one-note bot behavior. Only reply again if they asked a direct question or raised "
|
|
356
|
+
"a genuinely new point; otherwise let this one go or shift to a different angle."
|
|
357
|
+
)
|
|
320
358
|
if dm_lines:
|
|
321
359
|
parts.append("\n### DM threads on other posts")
|
|
322
360
|
parts.append("\n".join(dm_lines))
|
package/scripts/engage_github.py
CHANGED
|
@@ -306,9 +306,13 @@ def run_claude(prompt, timeout=300, session_id=None):
|
|
|
306
306
|
import time as _time
|
|
307
307
|
import select
|
|
308
308
|
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
309
|
+
# Route through run_claude.sh (2026-07-14) for session cost accounting +
|
|
310
|
+
# quota handling; tag engage-github is unmapped in TAG_TO_TYPE so this
|
|
311
|
+
# stays a direct claude -p. --session-id travels via the CLAUDE_SESSION_ID
|
|
312
|
+
# env (set below); the wrapper passes the flag itself, so adding it here
|
|
313
|
+
# too would hand claude a duplicate flag.
|
|
314
|
+
_run_claude_sh = os.path.join(os.path.dirname(os.path.abspath(__file__)), "run_claude.sh")
|
|
315
|
+
cmd = ["bash", _run_claude_sh, "engage-github", "-p", "--output-format", "stream-json", "--verbose"]
|
|
312
316
|
cmd += ["--tools", "Read"]
|
|
313
317
|
env = os.environ.copy()
|
|
314
318
|
env.pop("ANTHROPIC_API_KEY", None) # use OAuth, not API key
|
package/scripts/engage_reddit.py
CHANGED
|
@@ -583,9 +583,15 @@ def run_claude(prompt, timeout=300, session_id=None):
|
|
|
583
583
|
import time as _time
|
|
584
584
|
import select
|
|
585
585
|
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
586
|
+
# Route through run_claude.sh (2026-07-14) for session cost accounting +
|
|
587
|
+
# the dead-man's-switch quota handling every shell pipeline already gets.
|
|
588
|
+
# Tag engage-reddit is NOT in claude_job.py TAG_TO_TYPE, so this stays a
|
|
589
|
+
# direct claude -p (stream-json passes through the wrapper's tee).
|
|
590
|
+
# --session-id must NOT be passed here: the wrapper adds the flag itself
|
|
591
|
+
# and honors a caller-set CLAUDE_SESSION_ID env (set below), so passing
|
|
592
|
+
# it here too would hand claude a duplicate flag.
|
|
593
|
+
_run_claude_sh = os.path.join(os.path.dirname(os.path.abspath(__file__)), "run_claude.sh")
|
|
594
|
+
cmd = ["bash", _run_claude_sh, "engage-reddit", "-p", "--output-format", "stream-json", "--verbose"]
|
|
589
595
|
# --bare removed: it blocks OAuth auth which we need
|
|
590
596
|
cmd += ["--tools", "Bash,Read"]
|
|
591
597
|
env = os.environ.copy()
|