@oh-my-pi/pi-utils 14.9.9 → 15.0.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "14.9.9",
4
+ "version": "15.0.1",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -37,11 +37,11 @@
37
37
  "winston-daily-rotate-file": "^5.0.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/bun": "^1.3.13",
41
- "@oh-my-pi/pi-natives": "14.9.9"
40
+ "@types/bun": "^1.3.14",
41
+ "@oh-my-pi/pi-natives": "15.0.1"
42
42
  },
43
43
  "engines": {
44
- "bun": ">=1.3.7"
44
+ "bun": ">=1.3.14"
45
45
  },
46
46
  "files": [
47
47
  "src"
package/src/abortable.ts CHANGED
@@ -10,19 +10,6 @@ export class AbortError extends Error {
10
10
  }
11
11
  }
12
12
 
13
- /**
14
- * Sleep for a given number of milliseconds, respecting abort signal.
15
- *
16
- * Uses setTimeout (not Bun.sleep) so that vitest fake timers can intercept it in tests.
17
- */
18
- export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
19
- return untilAborted(signal, () => {
20
- const { promise, resolve } = Promise.withResolvers<void>();
21
- setTimeout(resolve, ms);
22
- return promise;
23
- });
24
- }
25
-
26
13
  /**
27
14
  * Creates an abortable stream from a given stream and signal.
28
15
  *
package/src/dirs.ts CHANGED
@@ -324,6 +324,17 @@ export function getGpuCachePath(): string {
324
324
  return dirs.rootSubdir("gpu_cache.json", "cache");
325
325
  }
326
326
 
327
+ /**
328
+ * Get the GitHub view cache database path (~/.omp/cache/github-cache.db).
329
+ * Honors the `OMP_GITHUB_CACHE_DB` env var when set so tests can isolate the
330
+ * cache file without touching the rest of the config root.
331
+ */
332
+ export function getGithubCacheDbPath(): string {
333
+ const override = process.env.OMP_GITHUB_CACHE_DB;
334
+ if (override) return override;
335
+ return dirs.rootSubdir(path.join("cache", "github-cache.db"), "cache");
336
+ }
337
+
327
338
  /** Get the natives directory (~/.omp/natives). */
328
339
  export function getNativesDir(): string {
329
340
  return dirs.rootSubdir("natives", "cache");
@@ -0,0 +1,290 @@
1
+ import { scheduler } from "node:timers/promises";
2
+
3
+ // "reset after 1h2m3s" / "10m15s" / "39s"
4
+ const QUOTA_RESET_PATTERN = /reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i;
5
+ // "Please retry in 250ms" / "Please retry in 12s"
6
+ const PLEASE_RETRY_PATTERN = /Please retry in ([0-9.]+)(ms|s)/i;
7
+ // JSON field: "retryDelay": "34.074824224s"
8
+ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
9
+ // "try again in 250ms" / "try again in 12s" / "try again in 12sec"
10
+ const TRY_AGAIN_PATTERN = /try again in\s+(\d+(?:\.\d+)?)\s*(ms|s)(?:ec)?/i;
11
+
12
+ /**
13
+ * Server-suggested retry delay extraction. Merges the patterns historically used
14
+ * by the OpenAI Codex and Google Gemini retry helpers.
15
+ *
16
+ * Header sources (checked in order):
17
+ * - `Retry-After` (numeric seconds, or HTTP date)
18
+ * - `x-ratelimit-reset` (Unix epoch seconds)
19
+ * - `x-ratelimit-reset-after` (seconds)
20
+ *
21
+ * Body patterns:
22
+ * - `Your quota will reset after 18h31m10s` / `10m15s` / `39s`
23
+ * - `Please retry in 250ms` / `Please retry in 12s`
24
+ * - `"retryDelay": "34.074824224s"` (JSON error detail field)
25
+ * - `try again in 250ms` / `try again in 12s` / `try again in 12sec`
26
+ *
27
+ * Returns `undefined` if no signal is found.
28
+ */
29
+ export function extractRetryHint(source: Response | Headers | null | undefined, body?: string): number | undefined {
30
+ const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
31
+ if (headers) {
32
+ const retryAfter = headers.get("retry-after");
33
+ if (retryAfter) {
34
+ const seconds = Number(retryAfter);
35
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
36
+ const parsedDate = Date.parse(retryAfter);
37
+ if (!Number.isNaN(parsedDate)) return Math.max(0, parsedDate - Date.now());
38
+ }
39
+ const rateLimitReset = headers.get("x-ratelimit-reset");
40
+ if (rateLimitReset) {
41
+ const resetSeconds = Number.parseInt(rateLimitReset, 10);
42
+ if (!Number.isNaN(resetSeconds)) {
43
+ const delta = resetSeconds * 1000 - Date.now();
44
+ if (delta > 0) return delta;
45
+ }
46
+ }
47
+ const rateLimitResetAfter = headers.get("x-ratelimit-reset-after");
48
+ if (rateLimitResetAfter) {
49
+ const seconds = Number(rateLimitResetAfter);
50
+ if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
51
+ }
52
+ }
53
+
54
+ if (!body) return undefined;
55
+
56
+ const quotaMatch = QUOTA_RESET_PATTERN.exec(body);
57
+ if (quotaMatch) {
58
+ const hours = quotaMatch[1] ? Number.parseInt(quotaMatch[1], 10) : 0;
59
+ const minutes = quotaMatch[2] ? Number.parseInt(quotaMatch[2], 10) : 0;
60
+ const seconds = Number.parseFloat(quotaMatch[3]!);
61
+ if (!Number.isNaN(seconds)) {
62
+ const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000;
63
+ if (totalMs > 0) return totalMs;
64
+ }
65
+ }
66
+ for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) {
67
+ const match = pattern.exec(body);
68
+ if (match?.[1]) {
69
+ const value = Number.parseFloat(match[1]);
70
+ if (Number.isFinite(value) && value > 0) {
71
+ return match[2]!.toLowerCase() === "ms" ? value : value * 1000;
72
+ }
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ export interface FetchWithRetryOptions extends RequestInit {
79
+ /** Total fetch attempts (initial + retries). Default `5`. */
80
+ maxAttempts?: number;
81
+ /**
82
+ * Per-delay cap. Server-provided `Retry-After` hints exceeding this return
83
+ * the current response immediately — caller deals with the `!response.ok`.
84
+ * Default `60_000`.
85
+ */
86
+ maxDelayMs?: number;
87
+ /**
88
+ * Fallback delay schedule when no server hint is present. Number, array
89
+ * (indexed by attempt, clamped to last), or function. Default exponential
90
+ * `500ms * 2 ** attempt` capped at `maxDelayMs`.
91
+ */
92
+ defaultDelayMs?: number | readonly number[] | ((attempt: number) => number);
93
+ /**
94
+ * Optional per-attempt overlay merged into the base `RequestInit` each try.
95
+ * Headers from the overlay shallow-merge over the base. Useful for auth
96
+ * token refresh or user-agent rotation.
97
+ */
98
+ prepareInit?: (attempt: number) => RequestInit | Promise<RequestInit>;
99
+ }
100
+
101
+ const DEFAULT_MAX_DELAY_MS = 60_000;
102
+ const DEFAULT_MAX_ATTEMPTS = 5;
103
+
104
+ /**
105
+ * Fetch with bounded retries and sensible defaults. Retries on any
106
+ * `isRetryableStatus` (5xx, 408, 429) and on transient network errors. Server
107
+ * `Retry-After`/quota hints are honoured up to `maxDelayMs`; a hint that exceeds
108
+ * the cap returns the current response so the caller can fail fast. Aborts on
109
+ * `init.signal` propagate as `"Request was aborted"`.
110
+ *
111
+ * The caller is responsible for inspecting `!response.ok` once the call returns.
112
+ */
113
+ export async function fetchWithRetry(
114
+ url: string | URL | ((attempt: number) => string | URL),
115
+ options: FetchWithRetryOptions = {},
116
+ ): Promise<Response> {
117
+ const {
118
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
119
+ maxDelayMs = DEFAULT_MAX_DELAY_MS,
120
+ defaultDelayMs,
121
+ prepareInit,
122
+ ...baseInit
123
+ } = options;
124
+ const signal = baseInit.signal as AbortSignal | undefined;
125
+
126
+ for (let attempt = 0; ; attempt++) {
127
+ if (signal?.aborted) throw new Error("Request was aborted");
128
+ const requestUrl = typeof url === "function" ? url(attempt) : url;
129
+ const init = prepareInit ? mergeInit(baseInit, await prepareInit(attempt)) : baseInit;
130
+
131
+ let response: Response;
132
+ try {
133
+ response = await fetch(requestUrl, init);
134
+ } catch (error) {
135
+ if (signal?.aborted) throw new Error("Request was aborted");
136
+ const wrapped = wrapNetworkError(error);
137
+ if (attempt + 1 >= maxAttempts) throw wrapped;
138
+ await scheduler.wait(resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), { signal });
139
+ continue;
140
+ }
141
+
142
+ if (!isRetryableStatus(response.status)) return response;
143
+ if (attempt + 1 >= maxAttempts) return response;
144
+
145
+ const hint = extractRetryHint(response, await response.clone().text());
146
+ if (hint !== undefined && hint > maxDelayMs) return response;
147
+
148
+ const delayMs = Math.min(hint ?? resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), maxDelayMs);
149
+ await scheduler.wait(delayMs, { signal });
150
+ }
151
+ }
152
+
153
+ function mergeInit(base: RequestInit, overlay: RequestInit): RequestInit {
154
+ const merged: RequestInit = { ...base, ...overlay };
155
+ if (base.headers || overlay.headers) {
156
+ const baseHeaders = new Headers(base.headers ?? undefined);
157
+ const overlayHeaders = new Headers(overlay.headers ?? undefined);
158
+ overlayHeaders.forEach((value, key) => {
159
+ baseHeaders.set(key, value);
160
+ });
161
+ merged.headers = baseHeaders;
162
+ }
163
+ return merged;
164
+ }
165
+
166
+ function wrapNetworkError(error: unknown): Error {
167
+ if (error instanceof Error) {
168
+ if (error.name === "AbortError" || error.message === "Request was aborted") {
169
+ return new Error("Request was aborted");
170
+ }
171
+ if (error.message === "fetch failed" && error.cause instanceof Error) {
172
+ return new Error(`Network error: ${error.cause.message}`);
173
+ }
174
+ return error;
175
+ }
176
+ return new Error(String(error));
177
+ }
178
+
179
+ function resolveDefaultDelay(
180
+ option: FetchWithRetryOptions["defaultDelayMs"],
181
+ attempt: number,
182
+ maxDelayMs: number,
183
+ ): number {
184
+ if (option === undefined) return Math.min(500 * 2 ** attempt, maxDelayMs);
185
+ if (typeof option === "number") return Math.min(option, maxDelayMs);
186
+ if (typeof option === "function") return Math.min(option(attempt), maxDelayMs);
187
+ return Math.min(option[Math.min(attempt, option.length - 1)] ?? 0, maxDelayMs);
188
+ }
189
+
190
+ /**
191
+ * Inspect an arbitrary error value (or its `cause` chain, up to depth 2) for an
192
+ * HTTP status code. Reads `status`, `statusCode`, and `response.status` fields,
193
+ * coerces string values, and falls back to scanning the error message for
194
+ * common patterns like `error (429)` or `HTTP 503`.
195
+ */
196
+ export function extractHttpStatusFromError(error: unknown): number | undefined {
197
+ return extractHttpStatusFromErrorInternal(error, 0);
198
+ }
199
+
200
+ type HttpErrorLike = {
201
+ message?: string;
202
+ name?: string;
203
+ status?: number | string;
204
+ statusCode?: number | string;
205
+ response?: { status?: number | string };
206
+ cause?: unknown;
207
+ };
208
+
209
+ function extractHttpStatusFromErrorInternal(error: unknown, depth: number): number | undefined {
210
+ if (!error || typeof error !== "object" || depth > 2) return undefined;
211
+ const info = error as HttpErrorLike;
212
+ const rawStatus = info.status ?? info.statusCode ?? info.response?.status;
213
+
214
+ let status: number | undefined;
215
+ if (typeof rawStatus === "number" && Number.isFinite(rawStatus)) {
216
+ status = rawStatus;
217
+ } else if (typeof rawStatus === "string") {
218
+ const parsed = Number(rawStatus);
219
+ if (Number.isFinite(parsed)) status = parsed;
220
+ }
221
+ if (status !== undefined && status >= 100 && status <= 599) return status;
222
+
223
+ if (info.message) {
224
+ const extracted = extractStatusFromMessage(info.message);
225
+ if (extracted !== undefined) return extracted;
226
+ }
227
+ if (info.cause) return extractHttpStatusFromErrorInternal(info.cause, depth + 1);
228
+ return undefined;
229
+ }
230
+
231
+ const STATUS_MESSAGE_PATTERNS = [
232
+ /error\s*\((\d{3})\)/i,
233
+ /status\s*[:=]?\s*(\d{3})/i,
234
+ /\bhttp\s*(\d{3})\b/i,
235
+ /\b(\d{3})\s*(?:status|error)\b/i,
236
+ ] as const;
237
+
238
+ function extractStatusFromMessage(message: string): number | undefined {
239
+ for (const pattern of STATUS_MESSAGE_PATTERNS) {
240
+ const match = pattern.exec(message);
241
+ if (!match) continue;
242
+ const value = Number(match[1]);
243
+ if (Number.isFinite(value) && value >= 100 && value <= 599) return value;
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ /**
249
+ * `true` if the given HTTP status code is one we treat as transient: 408
250
+ * (Request Timeout), 429 (Too Many Requests), or any 5xx (server error).
251
+ */
252
+ export function isRetryableStatus(status: number): boolean {
253
+ return status >= 500 || status === 408 || status === 429;
254
+ }
255
+
256
+ /**
257
+ * `true` if the message describes an unexpected socket closure — Bun and some
258
+ * proxies surface these for any HTTP/2 stream reset.
259
+ */
260
+ export function isUnexpectedSocketCloseMessage(message: string): boolean {
261
+ return /\b(?:the\s+)?socket connection (?:was )?closed unexpectedly\b/i.test(message);
262
+ }
263
+
264
+ const TRANSIENT_MESSAGE_PATTERN =
265
+ /overloaded|rate.?limit|too many requests|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|network error|stream stall|other side closed/i;
266
+
267
+ const VALIDATION_MESSAGE_PATTERN =
268
+ /invalid|validation|bad request|unsupported|schema|missing required|not found|unauthorized|forbidden/i;
269
+
270
+ /**
271
+ * Identify errors that should be retried: aborts/timeouts in the error name or
272
+ * message, retryable HTTP statuses (see `isRetryableStatus`), unexpected socket
273
+ * closes, and the standard transient phrases. 4xx statuses other than 408/429
274
+ * and validation-shaped messages short-circuit to `false`.
275
+ */
276
+ export function isRetryableError(error: unknown): boolean {
277
+ const info = error as { message?: string; name?: string } | null;
278
+ const message = info?.message ?? "";
279
+ const name = info?.name ?? "";
280
+ if (name === "AbortError" || /timeout|timed out|aborted/i.test(message)) return true;
281
+
282
+ const status = extractHttpStatusFromError(error);
283
+ if (status !== undefined) {
284
+ if (isRetryableStatus(status)) return true;
285
+ if (status >= 400 && status < 500) return false;
286
+ }
287
+
288
+ if (VALIDATION_MESSAGE_PATTERN.test(message)) return false;
289
+ return isUnexpectedSocketCloseMessage(message) || TRANSIENT_MESSAGE_PATTERN.test(message);
290
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
- export { abortableSleep, createAbortableStream, once, untilAborted } from "./abortable";
1
+ export { createAbortableStream, once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
3
  export * from "./color";
4
4
  export * from "./dirs";
5
5
  export * from "./env";
6
+ export * from "./fetch-retry";
6
7
  export * from "./format";
7
8
  export * from "./frontmatter";
8
9
  export * from "./fs-error";
package/src/prompt.ts CHANGED
@@ -8,7 +8,7 @@ export type PromptRenderPhase = "pre-render" | "post-render";
8
8
  export interface PromptFormatOptions {
9
9
  renderPhase?: PromptRenderPhase;
10
10
  replaceAsciiSymbols?: boolean;
11
- boldRfc2119Keywords?: boolean;
11
+ normalizeRfc2119?: boolean;
12
12
  }
13
13
 
14
14
  // Opening XML tag (not self-closing, not closing)
@@ -22,21 +22,27 @@ const TABLE_ROW = /^\|.*\|$/;
22
22
  // Table separator (|---|---|)
23
23
  const TABLE_SEP = /^\|[-:\s|]+\|$/;
24
24
 
25
- /** RFC 2119 keywords used in prompts. */
26
- const RFC2119_KEYWORDS = /\b(?:MUST NOT|SHOULD NOT|SHALL NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|SHALL|MUST|MAY)\b/g;
27
-
28
- function boldRfc2119Keywords(line: string): string {
29
- return line.replace(RFC2119_KEYWORDS, (match, offset, source) => {
30
- const isAlreadyBold =
31
- source[offset - 2] === "*" &&
32
- source[offset - 1] === "*" &&
33
- source[offset + match.length] === "*" &&
34
- source[offset + match.length + 1] === "*";
35
- if (isAlreadyBold) {
36
- return match;
37
- }
38
- return `**${match}**`;
39
- });
25
+ /**
26
+ * RFC 2119 keywords (plus project aliases NEVER/AVOID) wrapped in markdown bold
27
+ * — `**MUST**`, `**MUST NOT**`, `**NEVER**`, etc.
28
+ */
29
+ const RFC2119_BOLD = /\*\*(MUST NOT|SHOULD NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|MUST|MAY|NEVER|AVOID)\*\*/g;
30
+
31
+ /**
32
+ * Normalize RFC 2119 markers per project convention:
33
+ * - Strip `**KEYWORD**` bold (visual noise, no semantics).
34
+ * - Alias `MUST NOT` `NEVER` and `SHOULD NOT` → `AVOID` (single-token equivalents).
35
+ * Skips spans inside inline code (`` `…` ``) so alias definitions can be quoted literally.
36
+ */
37
+ function normalizeRfc2119(line: string): string {
38
+ const segments = line.split("`");
39
+ for (let i = 0; i < segments.length; i += 2) {
40
+ segments[i] = segments[i]
41
+ .replace(RFC2119_BOLD, "$1")
42
+ .replace(/\bMUST NOT\b/g, "NEVER")
43
+ .replace(/\bSHOULD NOT\b/g, "AVOID");
44
+ }
45
+ return segments.join("`");
40
46
  }
41
47
 
42
48
  /** Compact a table row by trimming cell padding */
@@ -60,6 +66,13 @@ function compactTableSep(line: string): string {
60
66
  return `|${normalized.join("|")}|`;
61
67
  }
62
68
 
69
+ const HTML_COMMENT_OPEN = "<!--";
70
+ const HTML_COMMENT_CLOSE = "-->";
71
+
72
+ type HtmlCommentState = {
73
+ inHtmlComment: boolean;
74
+ };
75
+
63
76
  function replaceCommonAsciiSymbols(line: string): string {
64
77
  return line
65
78
  .replace(/\.{3}/g, "…")
@@ -71,16 +84,59 @@ function replaceCommonAsciiSymbols(line: string): string {
71
84
  .replace(/>=/g, "≥");
72
85
  }
73
86
 
87
+ function replaceCommonAsciiSymbolsOutsideHtmlComments(line: string, state: HtmlCommentState): string {
88
+ if (!state.inHtmlComment && !line.includes(HTML_COMMENT_OPEN) && !line.includes(HTML_COMMENT_CLOSE)) {
89
+ return replaceCommonAsciiSymbols(line);
90
+ }
91
+
92
+ let result = "";
93
+ let cursor = 0;
94
+
95
+ while (cursor < line.length) {
96
+ if (state.inHtmlComment) {
97
+ const closeIndex = line.indexOf(HTML_COMMENT_CLOSE, cursor);
98
+ if (closeIndex === -1) {
99
+ return result + line.slice(cursor);
100
+ }
101
+ result += line.slice(cursor, closeIndex + HTML_COMMENT_CLOSE.length);
102
+ cursor = closeIndex + HTML_COMMENT_CLOSE.length;
103
+ state.inHtmlComment = false;
104
+ continue;
105
+ }
106
+
107
+ const openIndex = line.indexOf(HTML_COMMENT_OPEN, cursor);
108
+ if (openIndex === -1) {
109
+ result += replaceCommonAsciiSymbols(line.slice(cursor));
110
+ return result;
111
+ }
112
+
113
+ result += replaceCommonAsciiSymbols(line.slice(cursor, openIndex));
114
+ const closeIndex = line.indexOf(HTML_COMMENT_CLOSE, openIndex + HTML_COMMENT_OPEN.length);
115
+ if (closeIndex === -1) {
116
+ result += line.slice(openIndex);
117
+ state.inHtmlComment = true;
118
+ return result;
119
+ }
120
+
121
+ result += line.slice(openIndex, closeIndex + HTML_COMMENT_CLOSE.length);
122
+ cursor = closeIndex + HTML_COMMENT_CLOSE.length;
123
+ }
124
+
125
+ return result;
126
+ }
127
+
74
128
  export function format(content: string, options: PromptFormatOptions = {}): string {
75
129
  const {
76
130
  renderPhase = "post-render",
77
131
  replaceAsciiSymbols = false,
78
- boldRfc2119Keywords: shouldBoldRfc2119 = false,
132
+ normalizeRfc2119: shouldNormalizeRfc2119 = false,
79
133
  } = options;
80
134
  const isPreRender = renderPhase === "pre-render";
81
135
  const lines = content.split("\n");
82
136
  const result: string[] = [];
83
137
  let inCodeBlock = false;
138
+
139
+ const htmlCommentState: HtmlCommentState = { inHtmlComment: false };
84
140
  const topLevelTags: string[] = [];
85
141
 
86
142
  for (let i = 0; i < lines.length; i++) {
@@ -98,7 +154,7 @@ export function format(content: string, options: PromptFormatOptions = {}): stri
98
154
  }
99
155
 
100
156
  if (replaceAsciiSymbols) {
101
- line = replaceCommonAsciiSymbols(line);
157
+ line = replaceCommonAsciiSymbolsOutsideHtmlComments(line, htmlCommentState);
102
158
  }
103
159
  trimmedStart = line.trimStart();
104
160
  const trimmed = line.trim();
@@ -125,8 +181,8 @@ export function format(content: string, options: PromptFormatOptions = {}): stri
125
181
  line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`;
126
182
  }
127
183
 
128
- if (shouldBoldRfc2119) {
129
- line = boldRfc2119Keywords(line);
184
+ if (shouldNormalizeRfc2119) {
185
+ line = normalizeRfc2119(line);
130
186
  }
131
187
 
132
188
  if (trimmed === "") {