@m13v/s4l 1.7.12-rc.2 → 1.7.12-rc.5
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/browser-agent-configs/linkedin-harness-mcp.json +3 -1
- package/browser-agent-configs/twitter-harness-mcp.json +3 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/cdp_drive.py +102 -0
- package/scripts/cdp_ready_check.py +56 -9
- package/scripts/engage_github.py +49 -3
- package/scripts/ingest_human_github_replies.py +205 -0
- package/scripts/reply_db.py +19 -0
- package/scripts/scan_github_replies.py +34 -8
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"PATH": "__HOME__/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
|
|
15
15
|
"BU_NAME": "linkedin-harness",
|
|
16
16
|
"BH_PORT": "9556",
|
|
17
|
-
"BH_PROFILE_NAME": "browser-harness-linkedin"
|
|
17
|
+
"BH_PROFILE_NAME": "browser-harness-linkedin",
|
|
18
|
+
"BH_WINDOW_POS": "3814,-1050",
|
|
19
|
+
"BH_WINDOW_SIZE": "1024,1013"
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
}
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"__HOME__/.claude/mcp-servers/browser-harness/server.py"
|
|
12
12
|
],
|
|
13
13
|
"env": {
|
|
14
|
-
"PATH": "__HOME__/.local/bin:__NODE_BIN__:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
14
|
+
"PATH": "__HOME__/.local/bin:__NODE_BIN__:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
|
|
15
|
+
"BH_WINDOW_POS": "3042,-1032",
|
|
16
|
+
"BH_WINDOW_SIZE": "1024,1013"
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
}
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.12-rc.
|
|
5
|
+
"version": "1.7.12-rc.5",
|
|
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.12-rc.
|
|
3
|
+
"version": "1.7.12-rc.5",
|
|
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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Minimal CDP driver for the harness Chrome on port 9556.
|
|
3
|
+
Usage:
|
|
4
|
+
cdp_drive.py nav <url> -> navigate active page tab, print final href
|
|
5
|
+
cdp_drive.py href -> print current href of active page tab
|
|
6
|
+
cdp_drive.py shot <path> -> screenshot active page tab to path
|
|
7
|
+
cdp_drive.py click <x> <y> -> dispatch mouse click at viewport coords
|
|
8
|
+
cdp_drive.py eval <expr> -> evaluate JS expression, print JSON result
|
|
9
|
+
"""
|
|
10
|
+
import sys, json, time, base64
|
|
11
|
+
import urllib.request
|
|
12
|
+
import websocket # websocket-client
|
|
13
|
+
|
|
14
|
+
PORT = 9556
|
|
15
|
+
BASE = f"http://localhost:{PORT}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def list_tabs():
|
|
19
|
+
with urllib.request.urlopen(f"{BASE}/json/list", timeout=10) as r:
|
|
20
|
+
return json.load(r)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def active_page():
|
|
24
|
+
tabs = list_tabs()
|
|
25
|
+
pages = [t for t in tabs if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
|
|
26
|
+
# prefer a linkedin tab, else first page
|
|
27
|
+
for t in pages:
|
|
28
|
+
if "linkedin.com" in (t.get("url") or ""):
|
|
29
|
+
return t
|
|
30
|
+
if pages:
|
|
31
|
+
return pages[0]
|
|
32
|
+
raise RuntimeError("no page tab")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CDP:
|
|
36
|
+
def __init__(self, ws_url):
|
|
37
|
+
self.ws = websocket.create_connection(
|
|
38
|
+
ws_url, max_size=None, timeout=60, suppress_origin=True
|
|
39
|
+
)
|
|
40
|
+
self._id = 0
|
|
41
|
+
|
|
42
|
+
def cmd(self, method, **params):
|
|
43
|
+
self._id += 1
|
|
44
|
+
mid = self._id
|
|
45
|
+
self.ws.send(json.dumps({"id": mid, "method": method, "params": params}))
|
|
46
|
+
while True:
|
|
47
|
+
msg = json.loads(self.ws.recv())
|
|
48
|
+
if msg.get("id") == mid:
|
|
49
|
+
if "error" in msg:
|
|
50
|
+
raise RuntimeError(msg["error"])
|
|
51
|
+
return msg.get("result", {})
|
|
52
|
+
|
|
53
|
+
def close(self):
|
|
54
|
+
try:
|
|
55
|
+
self.ws.close()
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_href(c):
|
|
61
|
+
r = c.cmd("Runtime.evaluate", expression="location.href", returnByValue=True)
|
|
62
|
+
return r.get("result", {}).get("value")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
action = sys.argv[1]
|
|
67
|
+
tab = active_page()
|
|
68
|
+
c = CDP(tab["webSocketDebuggerUrl"])
|
|
69
|
+
try:
|
|
70
|
+
if action in ("nav", "click"):
|
|
71
|
+
c.cmd("Page.enable")
|
|
72
|
+
if action == "nav":
|
|
73
|
+
url = sys.argv[2]
|
|
74
|
+
c.cmd("Page.navigate", url=url)
|
|
75
|
+
time.sleep(6)
|
|
76
|
+
print(get_href(c))
|
|
77
|
+
elif action == "href":
|
|
78
|
+
print(get_href(c))
|
|
79
|
+
elif action == "shot":
|
|
80
|
+
path = sys.argv[2]
|
|
81
|
+
r = c.cmd("Page.captureScreenshot", format="png")
|
|
82
|
+
with open(path, "wb") as f:
|
|
83
|
+
f.write(base64.b64decode(r["data"]))
|
|
84
|
+
print(path)
|
|
85
|
+
elif action == "click":
|
|
86
|
+
x = float(sys.argv[2]); y = float(sys.argv[3])
|
|
87
|
+
c.cmd("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button="left", clickCount=1)
|
|
88
|
+
c.cmd("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button="left", clickCount=1)
|
|
89
|
+
time.sleep(4)
|
|
90
|
+
print(get_href(c))
|
|
91
|
+
elif action == "eval":
|
|
92
|
+
expr = sys.argv[2]
|
|
93
|
+
r = c.cmd("Runtime.evaluate", expression=expr, returnByValue=True, awaitPromise=True)
|
|
94
|
+
print(json.dumps(r.get("result", {}).get("value")))
|
|
95
|
+
else:
|
|
96
|
+
print("unknown action", file=sys.stderr); sys.exit(2)
|
|
97
|
+
finally:
|
|
98
|
+
c.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
|
@@ -24,6 +24,18 @@ crashed ("Aw, Snap") keeps its title/url in every listing while sitting dead;
|
|
|
24
24
|
probe each page with a trivial Runtime.evaluate and reload it IN PLACE on
|
|
25
25
|
failure — fresh renderer, no kill, no new window, no focus change.
|
|
26
26
|
|
|
27
|
+
DEAD SESSION DISPATCH (2026-08-20, recurred 2026-09-01): a parked tab whose
|
|
28
|
+
DevTools SESSION dispatch is dead never replies to any per-tab command, so the
|
|
29
|
+
in-place Page.reload hangs too and cannot revive it — while the browser-level
|
|
30
|
+
ws stays healthy and this probe used to report ready anyway (Playwright then
|
|
31
|
+
hung at connect for every posting attempt; on 09-01 that burned all 5 drain
|
|
32
|
+
retries on two human-approved drafts). The reload is therefore RE-VERIFIED
|
|
33
|
+
with a second Runtime.evaluate; a tab that still doesn't answer gets REPLACED
|
|
34
|
+
via browser-level Target.createTarget(background=True) + Target.closeTarget
|
|
35
|
+
(the proven fix both incidents — no Chrome restart, no focus steal, session
|
|
36
|
+
cookies intact). Only if replacement itself fails does the verdict flip to
|
|
37
|
+
ready=false, so hc_ensure_browser's two-strike reap finally has a real signal.
|
|
38
|
+
|
|
27
39
|
Usage: cdp_ready_check.py [CDP_URL] [TIMEOUT_MS]
|
|
28
40
|
|
|
29
41
|
Prints a one-line JSON verdict to stdout (same shape/keys as before; mode is
|
|
@@ -84,9 +96,13 @@ def main() -> int:
|
|
|
84
96
|
info = _get_json(f"{url}/json/version", timeout=3)
|
|
85
97
|
_ws_call(info["webSocketDebuggerUrl"], "Browser.getVersion", timeout_s)
|
|
86
98
|
|
|
87
|
-
# Renderer-liveness sweep
|
|
88
|
-
#
|
|
99
|
+
# Renderer-liveness sweep. A crashed renderer revives with an in-place
|
|
100
|
+
# reload; a tab with dead session dispatch (reload hangs too) gets
|
|
101
|
+
# REPLACED via browser-level commands. Only an unfixable page tab
|
|
102
|
+
# fails the verdict — see DEAD SESSION DISPATCH in the docstring.
|
|
89
103
|
revived = 0
|
|
104
|
+
replaced = 0
|
|
105
|
+
dead = 0
|
|
90
106
|
pages = []
|
|
91
107
|
try:
|
|
92
108
|
pages = [t for t in _get_json(f"{url}/json/list", timeout=3)
|
|
@@ -95,23 +111,54 @@ def main() -> int:
|
|
|
95
111
|
try:
|
|
96
112
|
_ws_call(t["webSocketDebuggerUrl"], "Runtime.evaluate",
|
|
97
113
|
4.0, expression="1")
|
|
114
|
+
continue
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
try:
|
|
118
|
+
_ws_call(t["webSocketDebuggerUrl"], "Page.reload", 8.0)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
# Re-verify: a reload that went through proves dispatch is
|
|
122
|
+
# alive again; one that hung proves it never will be.
|
|
123
|
+
try:
|
|
124
|
+
_ws_call(t["webSocketDebuggerUrl"], "Runtime.evaluate",
|
|
125
|
+
4.0, expression="1")
|
|
126
|
+
revived += 1
|
|
127
|
+
continue
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
# Dead session dispatch: replace the TAB, keep Chrome. Only
|
|
131
|
+
# http(s) tabs are recreated (the harness daemon's
|
|
132
|
+
# is_real_page refuses about:/chrome: tabs); the dead one is
|
|
133
|
+
# closed only after its replacement exists so the browser is
|
|
134
|
+
# never left tabless.
|
|
135
|
+
page_url = t.get("url") or ""
|
|
136
|
+
try:
|
|
137
|
+
if not page_url.startswith("http"):
|
|
138
|
+
raise ValueError(f"unreplaceable url {page_url[:40]!r}")
|
|
139
|
+
_ws_call(info["webSocketDebuggerUrl"], "Target.createTarget",
|
|
140
|
+
8.0, url=page_url, background=True)
|
|
141
|
+
_ws_call(info["webSocketDebuggerUrl"], "Target.closeTarget",
|
|
142
|
+
8.0, targetId=t["id"])
|
|
143
|
+
replaced += 1
|
|
98
144
|
except Exception:
|
|
99
|
-
|
|
100
|
-
_ws_call(t["webSocketDebuggerUrl"], "Page.reload", 8.0)
|
|
101
|
-
revived += 1
|
|
102
|
-
except Exception:
|
|
103
|
-
pass
|
|
145
|
+
dead += 1
|
|
104
146
|
except Exception:
|
|
105
147
|
pass
|
|
106
148
|
|
|
107
149
|
out = {
|
|
108
|
-
"ready":
|
|
150
|
+
"ready": dead == 0, "mode": "raw_ws", "contexts": len(pages),
|
|
109
151
|
"elapsed_s": round(time.time() - t0, 2),
|
|
110
152
|
}
|
|
111
153
|
if revived:
|
|
112
154
|
out["revived"] = revived
|
|
155
|
+
if replaced:
|
|
156
|
+
out["replaced"] = replaced
|
|
157
|
+
if dead:
|
|
158
|
+
out["dead_pages"] = dead
|
|
159
|
+
out["error"] = "page_session_dead_unreplaceable"
|
|
113
160
|
print(json.dumps(out))
|
|
114
|
-
return 0
|
|
161
|
+
return 0 if dead == 0 else 1
|
|
115
162
|
except Exception as e:
|
|
116
163
|
print(json.dumps({
|
|
117
164
|
"ready": False, "mode": "raw_ws",
|
package/scripts/engage_github.py
CHANGED
|
@@ -264,11 +264,27 @@ classification, output reason='blocklist_added:HANDLE:bot' or
|
|
|
264
264
|
- Tier 2: Only if the thread is explicitly about a topic one of our projects solves AND nobody has offered a comparable tool yet AND the maintainer hasn't already resolved it. Mention casually.
|
|
265
265
|
- Tier 3: Only if someone explicitly asks "what do you use" / "any tools for this" / "link?". Then give it directly.
|
|
266
266
|
|
|
267
|
-
## Decision step: reply or skip?
|
|
267
|
+
## Decision step: escalate, reply, or skip?
|
|
268
268
|
|
|
269
269
|
Read the FULL thread above. There is NO cap on how many times we can reply to a thread. Active back-and-forth is encouraged when the conversation keeps developing and we have something useful to contribute. Do not skip just because we have prior comments in the thread. Skip only when one of the specific conditions below is clearly true.
|
|
270
270
|
|
|
271
|
-
|
|
271
|
+
CHECK ESCALATION FIRST. Escalation hands the thread to the human who owns
|
|
272
|
+
this account; they answer by email and their answer is posted as the
|
|
273
|
+
comment. ESCALATE (output action=escalate with a one-line reason) when any
|
|
274
|
+
of these is true:
|
|
275
|
+
- The replier directly addresses OUR author (by @mention or unmistakable
|
|
276
|
+
context) with a question about something WE said, e.g. "@{our_username}
|
|
277
|
+
could you expand on what you mean?". A personal question to us deserves
|
|
278
|
+
the human's own answer, not a generated one.
|
|
279
|
+
- The replier reports a bug, breakage, or defect in one of OUR products
|
|
280
|
+
(anything in config.json). Bug reports need the maker's eyes.
|
|
281
|
+
- The replier is the repo OWNER (or an obvious maintainer) engaging
|
|
282
|
+
seriously with our comment. Maintainer conversations are relationships
|
|
283
|
+
the human should hold personally.
|
|
284
|
+
When a comment fits both ESCALATE and REPLY, escalate. Do not draft a
|
|
285
|
+
reply text for an escalated comment.
|
|
286
|
+
|
|
287
|
+
Otherwise, DEFAULT TO REPLY when you have substance. Lean toward engagement, not silence.
|
|
272
288
|
|
|
273
289
|
SKIP (output action=skip) only when one of these is clearly true:
|
|
274
290
|
- light_acknowledgment: the triggering comment is just thanks, emoji, +1, or other content-free acknowledgment
|
|
@@ -291,6 +307,9 @@ Output ONLY ONE JSON object. No markdown, no prose, no explanations, no code fen
|
|
|
291
307
|
For skip:
|
|
292
308
|
{{"action": "skip", "reason": "REASON_FROM_LIST_ABOVE"}}
|
|
293
309
|
|
|
310
|
+
For escalate:
|
|
311
|
+
{{"action": "escalate", "reason": "ONE_LINE_WHY_THE_HUMAN_SHOULD_ANSWER"}}
|
|
312
|
+
|
|
294
313
|
For reply:
|
|
295
314
|
{{"action": "reply", "text": "YOUR_REPLY_TEXT", "project": null, "engagement_style": "STYLE_NAME"}}
|
|
296
315
|
|
|
@@ -463,6 +482,7 @@ def main():
|
|
|
463
482
|
processed = 0
|
|
464
483
|
succeeded = 0
|
|
465
484
|
skipped = 0
|
|
485
|
+
escalated = 0
|
|
466
486
|
failed = 0
|
|
467
487
|
total_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
|
|
468
488
|
|
|
@@ -601,6 +621,28 @@ def main():
|
|
|
601
621
|
skipped += 1
|
|
602
622
|
print(f"[engage_github] #{reply['id']} SKIPPED: {reason} ({reply_elapsed:.0f}s) "
|
|
603
623
|
f"[${usage['cost_usd']:.4f}]")
|
|
624
|
+
elif decision.get("action") == "escalate":
|
|
625
|
+
# Flag-human escalation (2026-09-01): routes through
|
|
626
|
+
# reply_db.py escalated -> POST /api/v1/replies/{id}/flag-human,
|
|
627
|
+
# which sets status='escalated' and emails the [GH #id]
|
|
628
|
+
# escalation card from matt@s4l.ai. The human's Gmail reply is
|
|
629
|
+
# posted back to this thread verbatim by
|
|
630
|
+
# scripts/ingest_human_github_replies.py.
|
|
631
|
+
reason = (decision.get("reason") or "escalated by engage judgment").strip()
|
|
632
|
+
esc_res = subprocess.run(
|
|
633
|
+
[PYTHON, REPLY_DB, "escalated", str(reply["id"]), reason],
|
|
634
|
+
capture_output=True, text=True,
|
|
635
|
+
)
|
|
636
|
+
if esc_res.returncode == 0:
|
|
637
|
+
escalated += 1
|
|
638
|
+
print(f"[engage_github] #{reply['id']} ESCALATED: {reason} "
|
|
639
|
+
f"({reply_elapsed:.0f}s) [{(esc_res.stdout or '').strip()[:120]}]")
|
|
640
|
+
else:
|
|
641
|
+
# Escalation transport failed; leave the row pending so the
|
|
642
|
+
# next cycle retries rather than losing the flag.
|
|
643
|
+
failed += 1
|
|
644
|
+
print(f"[engage_github] #{reply['id']} ESCALATE FAILED: "
|
|
645
|
+
f"{(esc_res.stderr or esc_res.stdout or '')[:200]}")
|
|
604
646
|
elif decision.get("action") == "reply":
|
|
605
647
|
reply_text = (decision.get("text") or "").strip()
|
|
606
648
|
project = decision.get("project")
|
|
@@ -648,7 +690,7 @@ def main():
|
|
|
648
690
|
total_elapsed = time.time() - start_time
|
|
649
691
|
print(f"\n[engage_github] === SUMMARY ===")
|
|
650
692
|
print(f"[engage_github] processed={processed} succeeded={succeeded} "
|
|
651
|
-
f"skipped={skipped} failed={failed} elapsed={total_elapsed:.0f}s")
|
|
693
|
+
f"skipped={skipped} escalated={escalated} failed={failed} elapsed={total_elapsed:.0f}s")
|
|
652
694
|
print(f"[engage_github] Total tokens: input={total_usage['input_tokens']} "
|
|
653
695
|
f"output={total_usage['output_tokens']} "
|
|
654
696
|
f"cache_read={total_usage['cache_read']} cache_create={total_usage['cache_create']}")
|
|
@@ -659,10 +701,14 @@ def main():
|
|
|
659
701
|
# Canonical machine-readable summary line. github-engage.sh greps this and
|
|
660
702
|
# writes ONE log_run.py row that also carries Phase A scan counters. See
|
|
661
703
|
# the comment in engage_reddit.py for the duplicate-row history.
|
|
704
|
+
# escalated= rides along for observability; github-engage.sh's greps are
|
|
705
|
+
# token-anchored (posted=/skipped=/failed=/cost=) so the extra token is
|
|
706
|
+
# invisible to the existing log_run parsing.
|
|
662
707
|
print(
|
|
663
708
|
f"[engage_github] LOG_RUN_SUMMARY"
|
|
664
709
|
f" posted={succeeded}"
|
|
665
710
|
f" skipped={skipped}"
|
|
711
|
+
f" escalated={escalated}"
|
|
666
712
|
f" failed={failed}"
|
|
667
713
|
f" cost={total_usage['cost_usd']:.4f}"
|
|
668
714
|
f" elapsed={int(total_elapsed)}"
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Ingest human replies to GitHub escalation emails from Gmail and post them.
|
|
3
|
+
|
|
4
|
+
GitHub-lane mirror of ingest_human_dm_replies.py. Flow:
|
|
5
|
+
1. engage_github.py decides action='escalate' -> reply_db.py escalated ->
|
|
6
|
+
POST /api/v1/replies/{id}/flag-human sets status='escalated' and sends an
|
|
7
|
+
escalation email with subject `[GH #<reply_id>] <author> [github]: <reason>`
|
|
8
|
+
FROM matt@s4l.ai TO NOTIFICATION_EMAIL (i@m13v.com).
|
|
9
|
+
2. The human hits Reply in Gmail and writes the answer. Because the
|
|
10
|
+
escalation's From is matt@s4l.ai (a send-as alias), the reply lands in the
|
|
11
|
+
matt@s4l.ai mailbox as a fresh unread inbound message.
|
|
12
|
+
3. This script polls that mailbox for unread `Re: [GH #N]` messages. For each,
|
|
13
|
+
it extracts the reply_id, strips quoted history, and posts the text
|
|
14
|
+
VERBATIM as a comment on the GitHub thread via gh CLI (unlike the DM lane,
|
|
15
|
+
there is no rewrite step; what the human wrote is what appears).
|
|
16
|
+
4. The replies row flips to status='replied' with the human's text and the
|
|
17
|
+
posted comment URL; "skip"/"ignore"/"not relevant" bodies dismiss the
|
|
18
|
+
escalation instead (status='skipped', no comment posted).
|
|
19
|
+
5. The Gmail message is marked read so it is never re-ingested; rows not in
|
|
20
|
+
status='escalated' are never posted (double-post guard for stale emails).
|
|
21
|
+
|
|
22
|
+
Auth: same keyless DWD lane as the DM ingest (helpers imported from
|
|
23
|
+
ingest_human_dm_replies.py, which owns the token refresh logic).
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
python3 scripts/ingest_human_github_replies.py # ingest and post
|
|
27
|
+
python3 scripts/ingest_human_github_replies.py --dry-run # print actions, no posts, no DB writes, no label changes
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
37
|
+
from http_api import api_get, api_patch
|
|
38
|
+
from ingest_human_dm_replies import (
|
|
39
|
+
gmail_service,
|
|
40
|
+
fetch_raw,
|
|
41
|
+
pick_plain_body,
|
|
42
|
+
strip_quoted_history,
|
|
43
|
+
extract_sender_addr,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
GH_ID_RE = re.compile(r"\[GH\s*#(\d+)\]", re.IGNORECASE)
|
|
47
|
+
RE_PREFIX_RE = re.compile(r"^\s*re\s*:", re.IGNORECASE)
|
|
48
|
+
GMAIL_QUERY = 'is:unread subject:"Re: [GH #"'
|
|
49
|
+
DISMISS_WORDS = {"skip", "ignore", "not relevant"}
|
|
50
|
+
|
|
51
|
+
ISSUE_URL_RE = re.compile(r"github\.com/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_issue_coords(url):
|
|
55
|
+
m = ISSUE_URL_RE.search(url or "")
|
|
56
|
+
if not m:
|
|
57
|
+
return None, None, None
|
|
58
|
+
return m.group(1), m.group(2), int(m.group(3))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def post_comment(owner, repo, number, body):
|
|
62
|
+
"""Post via gh CLI; returns (ok, url_or_error). Same shape as engage_github."""
|
|
63
|
+
try:
|
|
64
|
+
out = subprocess.check_output(
|
|
65
|
+
["gh", "issue", "comment", str(number), "-R", f"{owner}/{repo}", "--body", body],
|
|
66
|
+
text=True, timeout=60, stderr=subprocess.STDOUT,
|
|
67
|
+
)
|
|
68
|
+
for line in out.strip().splitlines():
|
|
69
|
+
if line.startswith("https://github.com"):
|
|
70
|
+
return True, line.strip()
|
|
71
|
+
return True, None
|
|
72
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
73
|
+
err = e.output if hasattr(e, "output") and e.output else str(e)
|
|
74
|
+
return False, str(err)[:300]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def mark_read(service, gmail_id):
|
|
78
|
+
try:
|
|
79
|
+
service.users().messages().modify(
|
|
80
|
+
userId="me", id=gmail_id, body={"removeLabelIds": ["UNREAD"]}
|
|
81
|
+
).execute()
|
|
82
|
+
except Exception as e:
|
|
83
|
+
print(f" WARN {gmail_id}: could not mark as read: {e}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main():
|
|
87
|
+
parser = argparse.ArgumentParser()
|
|
88
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
89
|
+
help="Print what would be posted, do not post/patch/mark-read")
|
|
90
|
+
args = parser.parse_args()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
service = gmail_service()
|
|
94
|
+
except Exception as e:
|
|
95
|
+
print(f"FATAL: could not build Gmail service: {e}", file=sys.stderr)
|
|
96
|
+
sys.exit(2)
|
|
97
|
+
|
|
98
|
+
resp = service.users().messages().list(userId="me", q=GMAIL_QUERY, maxResults=50).execute()
|
|
99
|
+
candidates = resp.get("messages", []) or []
|
|
100
|
+
if not candidates:
|
|
101
|
+
print("No candidate Gmail messages for GitHub escalation replies.")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
posted = 0
|
|
105
|
+
dismissed = 0
|
|
106
|
+
skipped = 0
|
|
107
|
+
for c in candidates:
|
|
108
|
+
gmail_id = c["id"]
|
|
109
|
+
try:
|
|
110
|
+
email_msg, _labels = fetch_raw(service, gmail_id)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(f" SKIP {gmail_id}: fetch failed: {e}")
|
|
113
|
+
skipped += 1
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
subject = email_msg.get("Subject", "") or ""
|
|
117
|
+
sender = extract_sender_addr(email_msg.get("From", ""))
|
|
118
|
+
m = GH_ID_RE.search(subject)
|
|
119
|
+
if not m:
|
|
120
|
+
print(f" SKIP {gmail_id}: subject has no [GH #N] token ({subject!r})")
|
|
121
|
+
skipped += 1
|
|
122
|
+
continue
|
|
123
|
+
reply_id = int(m.group(1))
|
|
124
|
+
|
|
125
|
+
# Reject forwards / originals: only true Gmail replies count.
|
|
126
|
+
if not RE_PREFIX_RE.match(subject):
|
|
127
|
+
print(f" SKIP {gmail_id}: subject not a reply ({subject!r})")
|
|
128
|
+
skipped += 1
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
r_resp = api_get(f"/api/v1/replies/{reply_id}", ok_on_404=True)
|
|
132
|
+
row = (r_resp.get("data") or {}).get("reply") if r_resp.get("ok") else None
|
|
133
|
+
if not row:
|
|
134
|
+
print(f" SKIP {gmail_id}: reply #{reply_id} not found")
|
|
135
|
+
skipped += 1
|
|
136
|
+
if not args.dry_run:
|
|
137
|
+
mark_read(service, gmail_id)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Double-post guard: only an 'escalated' row is actionable. A row
|
|
141
|
+
# already flipped to replied/skipped means an earlier run (or a second
|
|
142
|
+
# email) handled it; mark the mail read and move on.
|
|
143
|
+
if row.get("status") != "escalated":
|
|
144
|
+
print(f" SKIP {gmail_id}: reply #{reply_id} status is "
|
|
145
|
+
f"'{row.get('status')}', not 'escalated'")
|
|
146
|
+
skipped += 1
|
|
147
|
+
if not args.dry_run:
|
|
148
|
+
mark_read(service, gmail_id)
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
body_raw = pick_plain_body(email_msg)
|
|
152
|
+
reply_text = strip_quoted_history(body_raw)
|
|
153
|
+
if not reply_text:
|
|
154
|
+
print(f" SKIP {gmail_id}: empty reply after stripping quoted history")
|
|
155
|
+
skipped += 1
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
owner, repo, number = parse_issue_coords(row.get("their_comment_url") or "")
|
|
159
|
+
if not owner:
|
|
160
|
+
print(f" SKIP {gmail_id}: reply #{reply_id} has no parseable "
|
|
161
|
+
f"their_comment_url ({row.get('their_comment_url')!r})")
|
|
162
|
+
skipped += 1
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if reply_text.strip().lower().rstrip(".!") in DISMISS_WORDS:
|
|
166
|
+
print(f" DISMISS {gmail_id}: reply #{reply_id} "
|
|
167
|
+
f"({owner}/{repo}#{number}) per '{reply_text.strip()}' from {sender}")
|
|
168
|
+
if not args.dry_run:
|
|
169
|
+
api_patch(f"/api/v1/replies/{reply_id}", {
|
|
170
|
+
"status": "skipped",
|
|
171
|
+
"skip_reason": "escalation_dismissed_by_human",
|
|
172
|
+
})
|
|
173
|
+
mark_read(service, gmail_id)
|
|
174
|
+
dismissed += 1
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
print(f" POST {gmail_id}: reply #{reply_id} -> {owner}/{repo}#{number}: "
|
|
178
|
+
f"{reply_text[:120]!r}")
|
|
179
|
+
if args.dry_run:
|
|
180
|
+
posted += 1
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
ok_post, url_or_err = post_comment(owner, repo, number, reply_text)
|
|
184
|
+
if not ok_post:
|
|
185
|
+
# Leave unread + escalated so the next run retries.
|
|
186
|
+
print(f" ERROR {gmail_id}: gh comment failed: {url_or_err}")
|
|
187
|
+
skipped += 1
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
api_patch(f"/api/v1/replies/{reply_id}", {
|
|
191
|
+
"status": "replied",
|
|
192
|
+
"our_reply_content": reply_text,
|
|
193
|
+
"our_reply_url": url_or_err,
|
|
194
|
+
"engagement_style": "human_escalation",
|
|
195
|
+
})
|
|
196
|
+
mark_read(service, gmail_id)
|
|
197
|
+
posted += 1
|
|
198
|
+
print(f" DONE reply #{reply_id} -> {url_or_err or '(no url)'}")
|
|
199
|
+
|
|
200
|
+
print(f"Done. Posted={posted} dismissed={dismissed} skipped={skipped} "
|
|
201
|
+
f"candidates={len(candidates)}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|
package/scripts/reply_db.py
CHANGED
|
@@ -189,6 +189,25 @@ elif cmd == "skip_batch":
|
|
|
189
189
|
"claude_session_id": CLAUDE_SESSION_ID,
|
|
190
190
|
})
|
|
191
191
|
print(f"ok {len(data['ids'])}")
|
|
192
|
+
elif cmd == "escalated":
|
|
193
|
+
# reply_db.py escalated ID "reason"
|
|
194
|
+
# GitHub reply lane flag-human escalation: routes through the dedicated
|
|
195
|
+
# POST /api/v1/replies/{id}/flag-human endpoint (NOT the generic PATCH),
|
|
196
|
+
# which sets status='escalated', stores the reason, and sends the
|
|
197
|
+
# [GH #id] escalation email from matt@s4l.ai in one round trip, mirroring
|
|
198
|
+
# dm_conversation.py's flag-human command. A row already replied or
|
|
199
|
+
# escalated comes back { flagged: false, skipped: true } and we print
|
|
200
|
+
# the skip instead of erroring, so a double-decision never 500s the lane.
|
|
201
|
+
from http_api import api_post
|
|
202
|
+
rid, reason = int(sys.argv[2]), sys.argv[3]
|
|
203
|
+
resp = api_post(f"/api/v1/replies/{rid}/flag-human", {"reason": reason},
|
|
204
|
+
ok_on_conflict=True)
|
|
205
|
+
data = ((resp or {}).get("data") or {})
|
|
206
|
+
if data.get("flagged"):
|
|
207
|
+
email = "sent" if data.get("email_sent") else "NOT SENT"
|
|
208
|
+
print(f"ok {rid} escalated (email {email})")
|
|
209
|
+
else:
|
|
210
|
+
print(f"skip {rid} not re-flagged ({data.get('reason', 'unknown')})")
|
|
192
211
|
elif cmd == "set_project":
|
|
193
212
|
# reply_db.py set_project ID "project_name"
|
|
194
213
|
# Used by engage_reddit.py to attribute a posted reply to a recommended
|
|
@@ -26,13 +26,18 @@ _cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
|
26
26
|
from config import config_path as _canonical_config_path, load_config
|
|
27
27
|
CONFIG_PATH = _canonical_config_path()
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
# 'github_issues'
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
29
|
+
# posts/replies for GitHub live under platform='github' in the DB. This was
|
|
30
|
+
# 'github_issues' (matching zero rows, making Phase A a no-op) until
|
|
31
|
+
# 2026-09-01, when the user asked for the deliberate flip. Volume is bounded
|
|
32
|
+
# so the flip does NOT scan all ~6.8k historical GitHub posts: only active
|
|
33
|
+
# posts from the last SCAN_WINDOW_DAYS are fetched (218 threads at flip time),
|
|
34
|
+
# and consecutive gh API failures back off and abort the scan instead of
|
|
35
|
+
# hammering a spent rate limit (Phase A.5 shares the same gh quota).
|
|
36
|
+
SCAN_PLATFORM = "github"
|
|
37
|
+
SCAN_WINDOW_DAYS = 60
|
|
38
|
+
# Stop the scan after this many CONSECUTIVE gh api failures: a run that hits
|
|
39
|
+
# this is rate-limited or offline, and every further call just burns quota.
|
|
40
|
+
MAX_CONSECUTIVE_ERRORS = 5
|
|
36
41
|
|
|
37
42
|
|
|
38
43
|
|
|
@@ -49,8 +54,12 @@ def main():
|
|
|
49
54
|
# Get all active GitHub posts we've commented on. The posts GET returns id +
|
|
50
55
|
# thread_url together, so we capture the post_id map here and skip the
|
|
51
56
|
# per-thread lookup the direct-SQL version used to do.
|
|
57
|
+
from datetime import datetime, timedelta, timezone
|
|
58
|
+
since_iso = (datetime.now(timezone.utc) - timedelta(days=SCAN_WINDOW_DAYS)).strftime(
|
|
59
|
+
"%Y-%m-%dT%H:%M:%SZ")
|
|
52
60
|
resp = api_get("/api/v1/posts",
|
|
53
|
-
query={"platform": SCAN_PLATFORM, "status": "active",
|
|
61
|
+
query={"platform": SCAN_PLATFORM, "status": "active",
|
|
62
|
+
"since": since_iso, "limit": 500})
|
|
54
63
|
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
55
64
|
|
|
56
65
|
issues = {}
|
|
@@ -80,10 +89,16 @@ def main():
|
|
|
80
89
|
discovered = 0
|
|
81
90
|
skipped = 0
|
|
82
91
|
errors = 0
|
|
92
|
+
consecutive_errors = 0
|
|
83
93
|
|
|
84
94
|
for issue_key, thread_url in issues.items():
|
|
85
95
|
repo, issue_num = issue_key.rsplit("/", 1)
|
|
86
96
|
|
|
97
|
+
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS:
|
|
98
|
+
print(f" ABORT: {consecutive_errors} consecutive gh api failures "
|
|
99
|
+
f"(rate limit / offline); stopping scan to preserve quota.")
|
|
100
|
+
break
|
|
101
|
+
|
|
87
102
|
# post_id captured alongside thread_url in the posts GET above.
|
|
88
103
|
post_id = post_id_by_url.get(thread_url)
|
|
89
104
|
if not post_id:
|
|
@@ -98,11 +113,22 @@ def main():
|
|
|
98
113
|
)
|
|
99
114
|
if result.returncode != 0:
|
|
100
115
|
errors += 1
|
|
116
|
+
consecutive_errors += 1
|
|
117
|
+
stderr_l = (result.stderr or "").lower()
|
|
118
|
+
if "rate limit" in stderr_l or "api rate limit exceeded" in stderr_l:
|
|
119
|
+
print(f" ABORT: gh rate limit hit on {issue_key}; stopping scan.")
|
|
120
|
+
break
|
|
121
|
+
# Exponential backoff on consecutive failures (2s, 4s, 8s, 16s)
|
|
122
|
+
# so a flapping API gets breathing room instead of a hammer.
|
|
123
|
+
time.sleep(min(2 ** consecutive_errors, 30))
|
|
101
124
|
continue
|
|
102
125
|
comments = json.loads(result.stdout) if result.stdout.strip() else []
|
|
126
|
+
consecutive_errors = 0
|
|
103
127
|
except Exception as e:
|
|
104
128
|
print(f" ERROR scanning {issue_key}: {e}")
|
|
105
129
|
errors += 1
|
|
130
|
+
consecutive_errors += 1
|
|
131
|
+
time.sleep(min(2 ** consecutive_errors, 30))
|
|
106
132
|
continue
|
|
107
133
|
|
|
108
134
|
# Find our comments to know their timestamps
|