@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,256 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""engagement_signal_feedback.py — turn downstream reply engagement into two
|
|
3
|
+
durable, whitelist-enforced learned_preferences_global entries:
|
|
4
|
+
|
|
5
|
+
1. Warm-signal authors: repeat, constructive, low-risk authors (matching the
|
|
6
|
+
same repeat_constructive_author shape reply_risk_digest.py already tags)
|
|
7
|
+
get promoted into `audience_prefer` so future judging treats them as a
|
|
8
|
+
positive signal instead of getting rediscovered from scratch every day.
|
|
9
|
+
|
|
10
|
+
2. Style performance: engagement_style values with enough sample size are
|
|
11
|
+
ranked by child-reply continuation rate (the strongest observed signal
|
|
12
|
+
for "this reply kept the thread going"); the current leader is recorded
|
|
13
|
+
as a `draft_style_notes` entry so the drafting model actually defaults to
|
|
14
|
+
the pattern that's been working, not just whatever the digest narrates in
|
|
15
|
+
a one-off email.
|
|
16
|
+
|
|
17
|
+
Both write through learned_preferences.apply_mutations(), the SAME
|
|
18
|
+
whitelist-enforced, flock+backup+atomic-replace writer the human review-card
|
|
19
|
+
feedback loop uses (see scripts/learned_preferences.py). This script is a
|
|
20
|
+
second, independent caller of that writer, not a new writer: the whitelist
|
|
21
|
+
enforcement lives in apply_mutations() itself, so nothing here can touch
|
|
22
|
+
config.json outside audience_prefer / draft_style_notes.
|
|
23
|
+
|
|
24
|
+
All DB reads go through the s4l.ai HTTP API (http_api), never direct SQL, so
|
|
25
|
+
this script works unmodified on shipped customer installs, not just this
|
|
26
|
+
operator box. Risk-scoring reuses reply_risk_digest._skip_reason_risk_score
|
|
27
|
+
by import (read-only; that file is intentionally not modified here) instead
|
|
28
|
+
of forking a second copy of the skip-risk heuristics.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
python3 scripts/engagement_signal_feedback.py --dry-run
|
|
32
|
+
python3 scripts/engagement_signal_feedback.py --platform x --days 30
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
from collections import Counter, defaultdict
|
|
41
|
+
from datetime import datetime, timedelta, timezone
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
REPO_DIR = Path(__file__).resolve().parent.parent
|
|
45
|
+
SCRIPT_DIR = REPO_DIR / "scripts"
|
|
46
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
47
|
+
|
|
48
|
+
from db import load_env # noqa: E402
|
|
49
|
+
from http_api import api_get # noqa: E402
|
|
50
|
+
from learned_preferences import apply_mutations, get_global_block # noqa: E402
|
|
51
|
+
from reply_risk_digest import _skip_reason_risk_score # noqa: E402
|
|
52
|
+
|
|
53
|
+
DEFAULT_DAYS = 30
|
|
54
|
+
MIN_STYLE_SAMPLE = 10
|
|
55
|
+
WARM_MIN_LAST_30D = 4
|
|
56
|
+
WARM_MIN_CHILD_REPLIES = 1
|
|
57
|
+
MAX_WARM_PROMOTIONS_PER_RUN = 3
|
|
58
|
+
ENTRY_CHAR_LIMIT = 200 # mirrors learned_preferences.MAX_ENTRY_CHARS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fetch_window_rows(platform: str, days: int, limit_pages: int = 20) -> list[dict]:
|
|
62
|
+
"""Page through /api/v1/replies for the lookback window, id-cursor style
|
|
63
|
+
(mirrors enrich_reply_parents.py's fetch_work loop). Capped at
|
|
64
|
+
limit_pages * 500 rows as a sane upper bound for a 30d window."""
|
|
65
|
+
since = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
|
|
66
|
+
out: list[dict] = []
|
|
67
|
+
before_id = None
|
|
68
|
+
for _ in range(limit_pages):
|
|
69
|
+
query = {"platform": platform, "since": since, "order_by": "id", "limit": "500"}
|
|
70
|
+
if before_id:
|
|
71
|
+
query["before_id"] = str(before_id)
|
|
72
|
+
resp = api_get("/api/v1/replies", query=query)
|
|
73
|
+
rows = (resp.get("data") or {}).get("replies") or []
|
|
74
|
+
if not rows:
|
|
75
|
+
break
|
|
76
|
+
out.extend(rows)
|
|
77
|
+
min_id = min(r["id"] for r in rows)
|
|
78
|
+
if before_id is not None and min_id >= before_id:
|
|
79
|
+
break
|
|
80
|
+
before_id = min_id
|
|
81
|
+
if len(rows) < 500:
|
|
82
|
+
break
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def compute_style_stats(rows: list[dict]) -> list[dict]:
|
|
87
|
+
by_style: dict[str, dict] = defaultdict(lambda: {"n": 0, "child_replies": 0, "upvotes": 0})
|
|
88
|
+
for r in rows:
|
|
89
|
+
if r.get("status") != "replied":
|
|
90
|
+
continue
|
|
91
|
+
style = r.get("engagement_style")
|
|
92
|
+
if not style:
|
|
93
|
+
continue
|
|
94
|
+
s = by_style[style]
|
|
95
|
+
s["n"] += 1
|
|
96
|
+
s["child_replies"] += int(r.get("comments_count") or 0)
|
|
97
|
+
s["upvotes"] += int(r.get("upvotes") or 0)
|
|
98
|
+
|
|
99
|
+
ranked = []
|
|
100
|
+
for style, s in by_style.items():
|
|
101
|
+
if s["n"] < MIN_STYLE_SAMPLE:
|
|
102
|
+
continue
|
|
103
|
+
ranked.append({
|
|
104
|
+
"style": style,
|
|
105
|
+
"n": s["n"],
|
|
106
|
+
"child_reply_rate": s["child_replies"] / s["n"],
|
|
107
|
+
"upvote_rate": s["upvotes"] / s["n"],
|
|
108
|
+
})
|
|
109
|
+
ranked.sort(key=lambda x: x["child_reply_rate"], reverse=True)
|
|
110
|
+
return ranked
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def compute_warm_authors(rows: list[dict]) -> list[dict]:
|
|
114
|
+
by_author: dict[str, dict] = defaultdict(lambda: {
|
|
115
|
+
"n": 0, "replied": 0, "riskish_skips": 0, "child_replies": 0,
|
|
116
|
+
"upvotes": 0, "projects": Counter(),
|
|
117
|
+
})
|
|
118
|
+
for r in rows:
|
|
119
|
+
handle = (r.get("their_author") or "").strip()
|
|
120
|
+
if not handle:
|
|
121
|
+
continue
|
|
122
|
+
a = by_author[handle]
|
|
123
|
+
a["n"] += 1
|
|
124
|
+
if r.get("status") == "replied":
|
|
125
|
+
a["replied"] += 1
|
|
126
|
+
if _skip_reason_risk_score(r.get("skip_reason") or "") >= 4:
|
|
127
|
+
a["riskish_skips"] += 1
|
|
128
|
+
a["child_replies"] += int(r.get("comments_count") or 0)
|
|
129
|
+
a["upvotes"] += int(r.get("upvotes") or 0)
|
|
130
|
+
if r.get("project_name"):
|
|
131
|
+
a["projects"][r["project_name"]] += 1
|
|
132
|
+
|
|
133
|
+
warm = []
|
|
134
|
+
for handle, a in by_author.items():
|
|
135
|
+
if a["n"] < WARM_MIN_LAST_30D:
|
|
136
|
+
continue
|
|
137
|
+
if a["riskish_skips"] > 0:
|
|
138
|
+
continue
|
|
139
|
+
if a["child_replies"] < WARM_MIN_CHILD_REPLIES:
|
|
140
|
+
continue
|
|
141
|
+
top_project = a["projects"].most_common(1)[0][0] if a["projects"] else None
|
|
142
|
+
warm.append({
|
|
143
|
+
"handle": handle,
|
|
144
|
+
"last_30d": a["n"],
|
|
145
|
+
"replied": a["replied"],
|
|
146
|
+
"child_replies": a["child_replies"],
|
|
147
|
+
"upvotes": a["upvotes"],
|
|
148
|
+
"project_name": top_project,
|
|
149
|
+
})
|
|
150
|
+
warm.sort(key=lambda x: (x["child_replies"], x["last_30d"]), reverse=True)
|
|
151
|
+
return warm
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def pick_default_project(cfg: dict, fallback_candidates: list[dict]) -> str | None:
|
|
155
|
+
for c in fallback_candidates:
|
|
156
|
+
if c.get("project_name"):
|
|
157
|
+
return c["project_name"]
|
|
158
|
+
projects = cfg.get("projects") or []
|
|
159
|
+
return projects[0].get("name") if projects else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_warm_entry(w: dict) -> str:
|
|
163
|
+
entry = (
|
|
164
|
+
f"@{w['handle']} — repeat constructive practitioner "
|
|
165
|
+
f"({w['replied']}/{w['last_30d']} replied in 30d, {w['child_replies']} child "
|
|
166
|
+
f"replies, 0 risk skips); warm to more engaged/product-relevant replies."
|
|
167
|
+
)
|
|
168
|
+
return entry[:ENTRY_CHAR_LIMIT]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_style_note(top: dict) -> str:
|
|
172
|
+
note = (
|
|
173
|
+
f"engagement_style='{top['style']}' shows the best child-reply continuation rate "
|
|
174
|
+
f"({top['child_reply_rate']:.2f}/reply, n={top['n']}); prefer it for technical-"
|
|
175
|
+
"correction replies (concrete metric, name the exact failure mode)."
|
|
176
|
+
)
|
|
177
|
+
if len(note) > ENTRY_CHAR_LIMIT:
|
|
178
|
+
note = note[: ENTRY_CHAR_LIMIT - 1].rsplit(" ", 1)[0] + "…"
|
|
179
|
+
return note
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main():
|
|
183
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
184
|
+
ap.add_argument("--platform", default="x")
|
|
185
|
+
ap.add_argument("--days", type=int, default=DEFAULT_DAYS)
|
|
186
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
187
|
+
args = ap.parse_args()
|
|
188
|
+
load_env()
|
|
189
|
+
|
|
190
|
+
rows = fetch_window_rows(args.platform, args.days)
|
|
191
|
+
print(f"[engagement_signal_feedback] fetched {len(rows)} rows over last {args.days}d ({args.platform})", file=sys.stderr)
|
|
192
|
+
|
|
193
|
+
style_ranked = compute_style_stats(rows)
|
|
194
|
+
warm_authors = compute_warm_authors(rows)[:MAX_WARM_PROMOTIONS_PER_RUN]
|
|
195
|
+
|
|
196
|
+
cfg_path_val = None
|
|
197
|
+
try:
|
|
198
|
+
from learned_preferences import config_path
|
|
199
|
+
cfg_path_val = config_path()
|
|
200
|
+
cfg = json.loads(Path(cfg_path_val).read_text())
|
|
201
|
+
except Exception as e:
|
|
202
|
+
print(f"[engagement_signal_feedback] cannot read config: {e}", file=sys.stderr)
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
block = get_global_block(cfg)
|
|
206
|
+
existing_prefer = block.get("audience_prefer") or []
|
|
207
|
+
existing_notes = block.get("draft_style_notes") or []
|
|
208
|
+
|
|
209
|
+
audience_prefer_add = []
|
|
210
|
+
for w in warm_authors:
|
|
211
|
+
handle_lower = w["handle"].lower()
|
|
212
|
+
already = any(f"@{handle_lower}" in e.lower() for e in existing_prefer)
|
|
213
|
+
if already:
|
|
214
|
+
continue
|
|
215
|
+
audience_prefer_add.append(build_warm_entry(w))
|
|
216
|
+
|
|
217
|
+
draft_style_notes_add = []
|
|
218
|
+
if style_ranked:
|
|
219
|
+
top = style_ranked[0]
|
|
220
|
+
already_noted = any(f"engagement_style='{top['style']}'" in n for n in existing_notes)
|
|
221
|
+
if not already_noted:
|
|
222
|
+
draft_style_notes_add.append(build_style_note(top))
|
|
223
|
+
|
|
224
|
+
print("[engagement_signal_feedback] style ranking:", file=sys.stderr)
|
|
225
|
+
for s in style_ranked:
|
|
226
|
+
print(f" {s['style']}: n={s['n']} child_reply_rate={s['child_reply_rate']:.2f} upvote_rate={s['upvote_rate']:.2f}", file=sys.stderr)
|
|
227
|
+
print(f"[engagement_signal_feedback] warm candidates this run: {[w['handle'] for w in warm_authors]}", file=sys.stderr)
|
|
228
|
+
print(f"[engagement_signal_feedback] audience_prefer additions: {audience_prefer_add}", file=sys.stderr)
|
|
229
|
+
print(f"[engagement_signal_feedback] draft_style_notes additions: {draft_style_notes_add}", file=sys.stderr)
|
|
230
|
+
|
|
231
|
+
if not audience_prefer_add and not draft_style_notes_add:
|
|
232
|
+
print("[engagement_signal_feedback] nothing new to promote", file=sys.stderr)
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
if args.dry_run:
|
|
236
|
+
print("[engagement_signal_feedback] --dry-run: not writing", file=sys.stderr)
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
project_name = pick_default_project(cfg, warm_authors)
|
|
240
|
+
if not project_name:
|
|
241
|
+
print("[engagement_signal_feedback] no project available to validate write; aborting", file=sys.stderr)
|
|
242
|
+
return 1
|
|
243
|
+
|
|
244
|
+
plan = {"changes": {}, "rationale": "engagement_signal_feedback.py: downstream reply engagement (child-reply/upvote rates)"}
|
|
245
|
+
if audience_prefer_add:
|
|
246
|
+
plan["changes"]["audience_prefer"] = {"add": audience_prefer_add}
|
|
247
|
+
if draft_style_notes_add:
|
|
248
|
+
plan["changes"]["draft_style_notes"] = {"add": draft_style_notes_add}
|
|
249
|
+
|
|
250
|
+
result = apply_mutations(project_name, plan, cfg_path=cfg_path_val)
|
|
251
|
+
print(f"[engagement_signal_feedback] apply_mutations result: {json.dumps(result)}", file=sys.stderr)
|
|
252
|
+
return 0 if result.get("ok") else 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
sys.exit(main())
|
|
@@ -22,10 +22,12 @@ The feedback loop:
|
|
|
22
22
|
`learned_preferences_global` block, whitelist-enforced, with flock +
|
|
23
23
|
backup + atomic replace.
|
|
24
24
|
4. Enforcement is SOFT (prompt-level, never a deterministic filter): the
|
|
25
|
-
twitter prep prompt embeds
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
_instruction) reaches the judging/drafting
|
|
25
|
+
twitter prep prompt embeds the block exactly ONCE via
|
|
26
|
+
GLOBAL_LEARNED_PREFS_JSON (the 'Global learned preferences' line under
|
|
27
|
+
PROJECT ROUTING; per-project stamping retired 2026-07-14), so the block
|
|
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.
|
|
29
31
|
prompt_block() renders the same content for callers that want an
|
|
30
32
|
explicit section instead of the raw embed.
|
|
31
33
|
|
|
@@ -81,9 +83,9 @@ MAX_HISTORY = 50
|
|
|
81
83
|
MAX_EDIT_EXAMPLES = 5
|
|
82
84
|
MAX_EXAMPLE_CHARS = 600
|
|
83
85
|
|
|
84
|
-
# Travels inside the
|
|
85
|
-
#
|
|
86
|
-
# for the block. Split semantics (2026-07-03, after the persona lane kept
|
|
86
|
+
# Travels inside the global block the prep prompt embeds (once, via
|
|
87
|
+
# GLOBAL_LEARNED_PREFS_JSON), so the drafting model reads its own operating
|
|
88
|
+
# manual for the block. Split semantics (2026-07-03, after the persona lane kept
|
|
87
89
|
# recycling a rejected draft structure): candidate JUDGING signals stay soft
|
|
88
90
|
# (judgment, not a hard ban), but draft_style_notes is MANDATORY when
|
|
89
91
|
# writing; if it doesn't beat the engagement style's structural template,
|
|
@@ -108,13 +110,34 @@ DEFAULT_INSTRUCTION = (
|
|
|
108
110
|
|
|
109
111
|
|
|
110
112
|
def config_path() -> str:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
"""Delegates to scripts/config.py, THE config resolver (state-dir first,
|
|
114
|
+
symlinks resolved to their real target). This module's os.replace()
|
|
115
|
+
writers previously flattened the operator Mac's config symlink into a
|
|
116
|
+
plain file whenever S4L_CONFIG_PATH was missing from the environment
|
|
117
|
+
(2026-07-11 and 2026-07-13 incidents); the shared resolver's realpath
|
|
118
|
+
makes that impossible regardless of env."""
|
|
119
|
+
try:
|
|
120
|
+
from config import config_path as _shared
|
|
121
|
+
|
|
122
|
+
return _shared()
|
|
123
|
+
except Exception:
|
|
124
|
+
# Direct-invocation fallback (scripts/ not importable): same order.
|
|
125
|
+
explicit = os.environ.get("S4L_CONFIG_PATH")
|
|
126
|
+
if explicit:
|
|
127
|
+
p = os.path.expanduser(explicit)
|
|
128
|
+
else:
|
|
129
|
+
state_dir = os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp"
|
|
130
|
+
sp = os.path.join(os.path.expanduser(state_dir), "config.json")
|
|
131
|
+
if os.path.exists(sp):
|
|
132
|
+
p = sp
|
|
133
|
+
else:
|
|
134
|
+
repo = os.environ.get("S4L_REPO_DIR")
|
|
135
|
+
p = (
|
|
136
|
+
os.path.join(repo, "config.json")
|
|
137
|
+
if repo
|
|
138
|
+
else os.path.expanduser("~/social-autoposter/config.json")
|
|
139
|
+
)
|
|
140
|
+
return os.path.realpath(p) if os.path.exists(p) else p
|
|
118
141
|
|
|
119
142
|
|
|
120
143
|
def _now_iso() -> str:
|
|
@@ -701,6 +701,7 @@ def build_summary() -> dict[str, Any]:
|
|
|
701
701
|
"twitter_cycle": twitter_cycle_status(),
|
|
702
702
|
"draft_publish": draft_publish_wrapper_status(),
|
|
703
703
|
"chrome_relaunches": chrome_relaunch_status(),
|
|
704
|
+
"chrome_crashpad": chrome_crashpad_status(),
|
|
704
705
|
"cdp_health": cdp_health_status(),
|
|
705
706
|
"process_count": len(rows),
|
|
706
707
|
"mem": {
|
|
@@ -955,6 +956,50 @@ def chrome_relaunch_status() -> dict[str, Any] | None:
|
|
|
955
956
|
return None
|
|
956
957
|
|
|
957
958
|
|
|
959
|
+
def chrome_crashpad_status() -> dict[str, Any] | None:
|
|
960
|
+
"""Chrome crash-dump rate from the GLOBAL Crashpad database.
|
|
961
|
+
|
|
962
|
+
macOS Chrome writes all crash dumps (any --user-data-dir) to
|
|
963
|
+
~/Library/Application Support/Google/Chrome/Crashpad/completed, NOT the
|
|
964
|
+
profile-local Crashpad dir. Baseline on a healthy box is ~1 dump/week;
|
|
965
|
+
the 2026-07-12 Chrome 150 regression produced 11-31/day of renderer
|
|
966
|
+
CHECK-aborts from the harness profile, and each crash preceded a CDP
|
|
967
|
+
websocket wedge by minutes (memory
|
|
968
|
+
insights_chrome150_renderer_crash_wedge_2026_07_14). Carrying the counts
|
|
969
|
+
plus the running Chrome version on the heartbeat makes the NEXT silent
|
|
970
|
+
Chrome-update regression visible in hours instead of days of stalls."""
|
|
971
|
+
try:
|
|
972
|
+
d = (
|
|
973
|
+
Path.home()
|
|
974
|
+
/ "Library/Application Support/Google/Chrome/Crashpad/completed"
|
|
975
|
+
)
|
|
976
|
+
if not d.is_dir():
|
|
977
|
+
return None
|
|
978
|
+
now = time.time()
|
|
979
|
+
count_24h = 0
|
|
980
|
+
total = 0
|
|
981
|
+
newest = 0.0
|
|
982
|
+
for f in d.iterdir():
|
|
983
|
+
if f.suffix != ".dmp":
|
|
984
|
+
continue
|
|
985
|
+
total += 1
|
|
986
|
+
try:
|
|
987
|
+
mt = f.stat().st_mtime
|
|
988
|
+
except OSError:
|
|
989
|
+
continue
|
|
990
|
+
if now - mt <= 86400:
|
|
991
|
+
count_24h += 1
|
|
992
|
+
newest = max(newest, mt)
|
|
993
|
+
out: dict[str, Any] = {"count_24h": count_24h, "total": total}
|
|
994
|
+
if newest:
|
|
995
|
+
out["last_dump_at"] = dt.datetime.fromtimestamp(
|
|
996
|
+
newest, dt.timezone.utc
|
|
997
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
998
|
+
return out
|
|
999
|
+
except Exception:
|
|
1000
|
+
return None
|
|
1001
|
+
|
|
1002
|
+
|
|
958
1003
|
def _tail_lines(path: Path, n: int, approx_line_bytes: int = 4096) -> list[str]:
|
|
959
1004
|
"""Return the last `n` lines of a possibly-large file without reading it all.
|
|
960
1005
|
Reads a bounded tail window (n * approx_line_bytes) from the end. Best-effort."""
|
|
@@ -123,6 +123,58 @@ def _thread_url(c: dict) -> str:
|
|
|
123
123
|
return ""
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
def _reddit_plan_to_candidates(plan: dict) -> list:
|
|
127
|
+
"""Adapt a post_reddit.py draft-phase plan into review-queue card entries.
|
|
128
|
+
|
|
129
|
+
The reddit plan shape is {project_name, batch_id, decisions: [...]}, where
|
|
130
|
+
each decision is the full post-ready dict (_draft_iteration's merged
|
|
131
|
+
candidate: thread_url, text, engagement_style, ripen data, campaign
|
|
132
|
+
fields). The card carries the twitter-shaped display fields the menu bar
|
|
133
|
+
knows how to render, PLUS the verbatim decision + plan metadata under
|
|
134
|
+
reddit_* keys so an approval can reconstruct a one-decision plan and hand
|
|
135
|
+
it to `post_reddit.py --phase post` unchanged (locks, URL wrapping,
|
|
136
|
+
campaign suffixes, and log_post all stay in the one battle-tested poster).
|
|
137
|
+
"""
|
|
138
|
+
import hashlib
|
|
139
|
+
|
|
140
|
+
out = []
|
|
141
|
+
for d in plan.get("decisions") or []:
|
|
142
|
+
if not isinstance(d, dict) or not d.get("text") or not d.get("thread_url"):
|
|
143
|
+
continue
|
|
144
|
+
# candidate_id gets an "rd-" prefix ON PURPOSE: bare integer ids here
|
|
145
|
+
# would collide with twitter_candidates ids in every id-keyed flow
|
|
146
|
+
# (review-events row flips, bulk-discard PATCH /twitter-candidates/
|
|
147
|
+
# by-id) and silently retire the WRONG twitter row. The prefixed id
|
|
148
|
+
# still satisfies index.ts's merge-by-candidate_id stamping (ids are
|
|
149
|
+
# compared as strings), while the twitter-table flips 404 harmlessly.
|
|
150
|
+
_rid = d.get("id") or d.get("candidate_id")
|
|
151
|
+
if not _rid:
|
|
152
|
+
_rid = hashlib.sha1(d["thread_url"].encode()).hexdigest()[:10]
|
|
153
|
+
out.append({
|
|
154
|
+
"platform": "reddit",
|
|
155
|
+
"candidate_id": f"rd-{_rid}",
|
|
156
|
+
"candidate_url": d.get("thread_url"),
|
|
157
|
+
"thread_url": d.get("thread_url"),
|
|
158
|
+
"thread_author": d.get("thread_author"),
|
|
159
|
+
"thread_text": d.get("thread_title") or "",
|
|
160
|
+
"matched_project": plan.get("project_name"),
|
|
161
|
+
"reply_text": d.get("text"),
|
|
162
|
+
"engagement_style": d.get("engagement_style"),
|
|
163
|
+
"assigned_style": d.get("engagement_style"),
|
|
164
|
+
"search_topic": d.get("search_topic"),
|
|
165
|
+
"reddit_batch_id": plan.get("batch_id"),
|
|
166
|
+
"reddit_decision": d,
|
|
167
|
+
"reddit_plan_meta": {
|
|
168
|
+
k: plan.get(k)
|
|
169
|
+
for k in ("project_name", "batch_id", "style_assignment",
|
|
170
|
+
"generation_trace_path", "session_id",
|
|
171
|
+
"draft_session_id")
|
|
172
|
+
if plan.get(k) is not None
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
126
178
|
# Discovery-time author/engagement fields stamped onto each plan candidate so the
|
|
127
179
|
# approval card can show them. All already captured on the twitter_candidates row
|
|
128
180
|
# by the discovery pipeline (and refreshed at T1); no scrape happens here.
|
|
@@ -174,6 +226,10 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
|
|
|
174
226
|
if not c.get("posted")
|
|
175
227
|
and not c.get("terminal")
|
|
176
228
|
and not c.get("approved")
|
|
229
|
+
# The bulk lookup is /api/v1/twitter-candidates; reddit cards would
|
|
230
|
+
# never match a row there, so skip them (their freshness gating stays
|
|
231
|
+
# with the reddit pipeline's own salvage/expiry lanes).
|
|
232
|
+
and c.get("platform") != "reddit"
|
|
177
233
|
and _thread_url(c)
|
|
178
234
|
]
|
|
179
235
|
if not pending:
|
|
@@ -216,16 +272,21 @@ def main() -> int:
|
|
|
216
272
|
"--plan-from-marker",
|
|
217
273
|
help="text containing a DRAFT_ONLY_PLAN=<path> marker (e.g. cycle stdout)",
|
|
218
274
|
)
|
|
275
|
+
ap.add_argument(
|
|
276
|
+
"--reddit-plan",
|
|
277
|
+
help="path to a post_reddit.py draft-phase plan (decisions[] shape); "
|
|
278
|
+
"adapted into reddit cards instead of the twitter candidates[] shape",
|
|
279
|
+
)
|
|
219
280
|
ap.add_argument("--project", default=None, help="project name for the review-request marker")
|
|
220
281
|
ns = ap.parse_args()
|
|
221
282
|
|
|
222
|
-
src = ns.plan
|
|
283
|
+
src = ns.plan or ns.reddit_plan
|
|
223
284
|
if not src and ns.plan_from_marker:
|
|
224
285
|
m = re.search(r"DRAFT_ONLY_PLAN=(\S+\.json)", ns.plan_from_marker)
|
|
225
286
|
if m:
|
|
226
287
|
src = m.group(1)
|
|
227
288
|
if not src:
|
|
228
|
-
print("[merge_review_queue] no source plan (need --plan or a DRAFT_ONLY_PLAN marker)", file=sys.stderr)
|
|
289
|
+
print("[merge_review_queue] no source plan (need --plan, --reddit-plan or a DRAFT_ONLY_PLAN marker)", file=sys.stderr)
|
|
229
290
|
return 2
|
|
230
291
|
if not os.path.exists(src):
|
|
231
292
|
print(f"[merge_review_queue] source plan not found: {src}", file=sys.stderr)
|
|
@@ -238,7 +299,10 @@ def main() -> int:
|
|
|
238
299
|
print(f"[merge_review_queue] could not read source plan: {e}", file=sys.stderr)
|
|
239
300
|
return 2
|
|
240
301
|
|
|
241
|
-
|
|
302
|
+
if ns.reddit_plan:
|
|
303
|
+
new_cands = _reddit_plan_to_candidates(batch)
|
|
304
|
+
else:
|
|
305
|
+
new_cands = batch.get("candidates") or []
|
|
242
306
|
if not new_cands:
|
|
243
307
|
print("[merge_review_queue] source plan has 0 candidates; nothing to merge", file=sys.stderr)
|
|
244
308
|
return 0
|