@beryl-so/cli 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { extractCode } from "../email-extract.js";
1
+ import { ApiError } from "../http.js";
2
2
  import { dim, green, table } from "../output.js";
3
3
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
4
4
  const mailboxPath = (ws) => `/workspaces/${ws}/mailboxes`;
@@ -89,7 +89,8 @@ export const mailboxCommands = [
89
89
  summary: "Read the latest email in a mailbox (waits for one to arrive)",
90
90
  description: "Waits up to --timeout-s for a matching email and returns it (one blocking request; " +
91
91
  "the server caps the wait at 50s — re-run to keep waiting). With --extract-code, " +
92
- "also pulls the one-time code (4-8 digits) out of the body/subject. Use " +
92
+ "also asks the server to pull the one-time code out of the email (AI-assisted when " +
93
+ "the email is ambiguous; `code` is null if none was found). Use " +
93
94
  "--recipient-contains to read only one `+tag` alias's mail when several identities " +
94
95
  "share the mailbox. Exits non-zero if nothing arrives before the timeout. Waits for " +
95
96
  "and returns ONE latest matching email — `mailbox emails` lists what has already " +
@@ -126,7 +127,8 @@ export const mailboxCommands = [
126
127
  ],
127
128
  async run(ctx, input) {
128
129
  const { workspaceId } = await ctx.requireProject(input);
129
- const email = (await ctx.client.get(`${mailboxPath(workspaceId)}/${arg(input, "mailbox-id")}/emails/latest`, {
130
+ const mailboxId = arg(input, "mailbox-id");
131
+ const email = (await ctx.client.get(`${mailboxPath(workspaceId)}/${mailboxId}/emails/latest`, {
130
132
  timeout_s: flagNum(input, "timeout-s"),
131
133
  since: flagStr(input, "since"),
132
134
  from_contains: flagStr(input, "from-contains"),
@@ -135,7 +137,18 @@ export const mailboxCommands = [
135
137
  }));
136
138
  if (!flagBool(input, "extract-code"))
137
139
  return { data: email };
138
- return { data: { ...email, code: extractCode(email) } };
140
+ // Extraction is the server's job (the same brain runs use, AI included); a 422
141
+ // means the email genuinely carries no code.
142
+ try {
143
+ const extracted = (await ctx.client.post(`${mailboxPath(workspaceId)}/${mailboxId}/emails/${email.id}/extract`, { extract: "code" }));
144
+ return { data: { ...email, code: extracted.value } };
145
+ }
146
+ catch (err) {
147
+ if (err instanceof ApiError && err.status === 422) {
148
+ return { data: { ...email, code: null } };
149
+ }
150
+ throw err;
151
+ }
139
152
  },
140
153
  },
141
154
  {
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs";
2
- import { extractValue } from "./email-extract.js";
3
2
  import { ApiError } from "./http.js";
4
3
  const SIDECAR_POLL_MS = 500;
5
4
  // The server caps one blocking wait at 50s; stay under it and loop.
6
5
  const SERVER_WAIT_MAX_S = 45;
7
6
  const DEFAULT_WAIT_S = 30;
7
+ const EXTRACT_RETRY_MS = 1000;
8
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
9
  export function startEmailPump(opts) {
10
10
  let stopped = false;
@@ -29,11 +29,36 @@ export function startEmailPump(opts) {
29
29
  throw err;
30
30
  }
31
31
  };
32
+ // A 422 is a real verdict — "this email carries no artifact" — and sends the wait on
33
+ // to the next email; anything else is transport trouble, retried until the step's own
34
+ // deadline does the failing.
35
+ const extractOnServer = async (emailId, req, deadline) => {
36
+ while (!stopped && Date.now() < deadline) {
37
+ try {
38
+ const res = (await opts.client.post(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/${emailId}/extract`, { extract: req.extract ?? "code", extract_pattern: req.pattern }));
39
+ if (res.source === "ai")
40
+ opts.onEvent?.("await_email: the AI extractor answered");
41
+ return { ok: true, value: res.value };
42
+ }
43
+ catch (err) {
44
+ if (err instanceof ApiError && err.status === 422) {
45
+ const detail = typeof err.detail === "string" ? err.detail : err.message;
46
+ return {
47
+ ok: false,
48
+ error: detail.replace(/^EMAIL_EXTRACTION_FAILED:\s*/, ""),
49
+ };
50
+ }
51
+ await sleep(EXTRACT_RETRY_MS);
52
+ }
53
+ }
54
+ return { ok: false, error: "extraction did not complete before the wait deadline" };
55
+ };
32
56
  const resolveRequest = async (req) => {
33
57
  // Wait exactly wait_s, like the cloud resolver — the spec's own grace window
34
58
  // covers this side's poll tick + round trip.
35
59
  const waitS = Math.max(1, Number(req.wait_s) || DEFAULT_WAIT_S);
36
60
  const deadline = Date.now() + waitS * 1000;
61
+ let lastExtractError;
37
62
  while (!stopped && Date.now() < deadline) {
38
63
  const remainingS = Math.ceil((deadline - Date.now()) / 1000);
39
64
  const email = await fetchLatest(req, Math.min(SERVER_WAIT_MAX_S, remainingS));
@@ -48,9 +73,15 @@ export function startEmailPump(opts) {
48
73
  consumed.add(email.id);
49
74
  since = email.received_at;
50
75
  opts.onEvent?.(`await_email: matched "${email.subject ?? "(no subject)"}"`);
51
- return extractValue(email, String(req.extract ?? "code"), req.pattern);
76
+ const extracted = await extractOnServer(email.id, req, deadline);
77
+ if (extracted.ok)
78
+ return extracted.value;
79
+ // Same move as the cloud resolver: an email without the artifact (a welcome mail
80
+ // racing the code mail) is consumed and the wait continues for the next one.
81
+ lastExtractError = extracted.error;
82
+ opts.onEvent?.(`await_email: ${extracted.error} — waiting for the next email`);
52
83
  }
53
- throw new Error(`no matching email arrived in the run inbox within ${waitS}s`);
84
+ throw new Error(lastExtractError ?? `no matching email arrived in the run inbox within ${waitS}s`);
54
85
  };
55
86
  const writeResponse = (response) => {
56
87
  let box = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,99 +0,0 @@
1
- import vm from "node:vm";
2
- // A labelled digit run ("your code is 654321") beats the bare fenced pattern, because a
3
- // real sign-in mail is full of innocent 4-8 digit runs — "© 2026", a support number —
4
- // and the bare fence would happily return the first of them.
5
- const CODE_PATTERN = /(?<!\d)(\d{4,8})(?!\d)/;
6
- const LABELLED_CODE_PATTERN = /(?:verification|security|one[\s-]?time|login|sign[\s-]?in|access|confirmation)?\s*(?:code|otp|passcode|pin)\b[^0-9]{0,20}(?<!\d)(\d{4,8})(?!\d)/i;
7
- const LINK_PATTERN = /https:\/\/[^\s"'<>)\]]+/;
8
- // An anchor's href is the click target in an HTML mail; a bare URL scan over raw markup
9
- // would return a CSS background or tracking pixel instead.
10
- const HREF_PATTERN = /<a\b[^>]*\bhref\s*=\s*["']?(https:\/\/[^\s"'>]+)/gi;
11
- // Footer furniture a sign-in mail carries but nobody means.
12
- const LINK_NOISE = [
13
- "unsubscribe",
14
- "/privacy",
15
- "/terms",
16
- "list-manage",
17
- "mailchimp",
18
- "sendgrid.net",
19
- "/track/",
20
- "/wf/open",
21
- "twitter.com",
22
- "facebook.com",
23
- "linkedin.com",
24
- ];
25
- // Matches the server's EMAIL_PATTERN_MAX_BODY_CHARS bound: backtracking cost scales with
26
- // haystack length, and no real sign-in code lives past the first few KB of an email.
27
- const PATTERN_MAX_BODY_CHARS = 20_000;
28
- const PATTERN_TIMEOUT_MS = 2_000;
29
- // An author-supplied regex against a sender-supplied body is the textbook ReDoS setup,
30
- // and native RegExp cannot be interrupted once it starts. Running the match inside a vm
31
- // context gives it a real wall-clock deadline — V8 services the timeout interrupt while
32
- // backtracking — matching the server's regex-engine timeout, whatever the pattern is.
33
- function matchWithDeadline(haystack, pattern) {
34
- try {
35
- return vm.runInNewContext("haystack.match(new RegExp(pattern))", { haystack, pattern }, { timeout: PATTERN_TIMEOUT_MS });
36
- }
37
- catch (err) {
38
- const code = err?.code;
39
- if (code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || /timed out/i.test(String(err))) {
40
- throw new Error("the extract_pattern took too long to match this email — it backtracks " +
41
- "explosively (an ambiguous quantifier like (a+)+ or (a|a)+ does this). " +
42
- "Rewrite it to match unambiguously.");
43
- }
44
- throw err;
45
- }
46
- }
47
- export function visibleText(html) {
48
- return html
49
- .replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ")
50
- .replace(/<[^>]+>/g, " ");
51
- }
52
- function bodyOf(email) {
53
- if (email.body_text)
54
- return email.body_text;
55
- if (email.body_html)
56
- return visibleText(email.body_html);
57
- return "";
58
- }
59
- export function extractCode(email) {
60
- for (const pattern of [LABELLED_CODE_PATTERN, CODE_PATTERN]) {
61
- for (const text of [bodyOf(email), email.subject ?? ""]) {
62
- const match = text.match(pattern);
63
- if (match)
64
- return match[1];
65
- }
66
- }
67
- return null;
68
- }
69
- export function extractLink(email) {
70
- const html = email.body_html ?? "";
71
- const hrefs = [...html.matchAll(HREF_PATTERN)].map((m) => m[1]);
72
- const candidates = hrefs.length > 0 ? hrefs : (bodyOf(email).match(LINK_PATTERN) ?? []);
73
- for (const url of candidates) {
74
- if (!LINK_NOISE.some((noise) => url.toLowerCase().includes(noise)))
75
- return url;
76
- }
77
- if (candidates.length > 0) {
78
- throw new Error("the email's only links look like footer/unsubscribe links, not a sign-in link");
79
- }
80
- throw new Error("the email arrived but carried no https link to follow");
81
- }
82
- export function extractValue(email, extract, pattern) {
83
- if (extract === "link")
84
- return extractLink(email);
85
- const body = bodyOf(email);
86
- if (extract === "pattern") {
87
- if (!pattern)
88
- throw new Error("extract=pattern needs an extract_pattern");
89
- const match = matchWithDeadline(body.slice(0, PATTERN_MAX_BODY_CHARS), pattern);
90
- if (!match) {
91
- throw new Error("the email arrived but nothing in it matched the extract_pattern");
92
- }
93
- return match[1] ?? match[0];
94
- }
95
- const code = extractCode(email);
96
- if (!code)
97
- throw new Error("the email arrived but carried no 4-8 digit sign-in code");
98
- return code;
99
- }