@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.
@@ -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
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.12-rc.2",
3
- "installedAt": "2026-08-20T01:59:29.922Z"
2
+ "version": "1.7.12-rc.5",
3
+ "installedAt": "2026-09-01T23:32:18.166Z"
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.12-rc.2",
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.2",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.12-rc.2",
3
+ "version": "1.7.12-rc.5",
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",
@@ -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: revive crashed tabs in place, best-effort;
88
- # revival must never fail the readiness verdict the wedge gate uses.
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
- try:
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": True, "mode": "raw_ws", "contexts": len(pages),
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",
@@ -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
- DEFAULT TO REPLY when you have substance. Lean toward engagement, not silence.
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()
@@ -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
- # NOTE: posts/replies for GitHub live under platform='github' in the DB; the
30
- # 'github_issues' value used here matches zero rows, so Phase A has long been a
31
- # no-op. Preserved verbatim during the HTTP-only migration to avoid an
32
- # unrequested volume/cost change (switching to 'github' would suddenly scan all
33
- # ~6.8k GitHub posts). If you want to actually scan GitHub replies, flip
34
- # SCAN_PLATFORM to 'github' deliberately.
35
- SCAN_PLATFORM = "github_issues"
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", "limit": 500})
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