Skip to content

utils

@alexkroman1/aai/utils — the zero-dependency helpers a TOOL body reaches for.

A FACADE. The subpath resolves here rather than at utils.ts, which buys two things the direct form could not. That module can be SPLIT as it grows without moving the published entry point — the path an implementation file happens to have is not a thing to promise anyone — and a name it gains next reaches the public surface only when a line is added below, rather than the moment it is written.

Named re-exports rather than export * for the second half of that: the wildcard form re-exports whatever arrives, and needs a noReExportAll suppression the escape-hatch ratchet only lets move down.

countWords(text): number

How many words a string holds — whitespace-separated runs, after trimming.

Every kind of whitespace separates (spaces, tabs, newlines, the non-breaking space a pasted transcript carries), and a run of them counts once, so a transcript stitched with "\n\n" between segments counts the same as one joined with single spaces. An empty or whitespace-only string is 0, which is the case a naive split(/\s+/).length gets wrong by returning 1.

Deliberately naive about what a “word” is: it does not know about hyphenation, contractions, CJK text with no spaces in it, or numerals. It exists for the one thing every template used it for — “~1,200 words” in a progress line beside a transcript — where the count is a SCALE a reader calibrates against, not a figure anything is computed from.

string

number

import { countWords } from "@alexkroman1/aai/utils";
countWords(" hello there\nfriend "); // 3
countWords(" "); // 0

decodeHtmlEntities(text): string

Decode the five XML/HTML entities that matter, plus a numeric apostrophe.

< > "   and &, plus ' / ' / ' for the apostrophe — the one that arrives numeric as often as named, because ' is XML and not in HTML 4. A non-breaking space becomes an ordinary space rather than U+00A0, since the caller is feeding text to a model or a word count, and countWords treating the two alike is the same decision.

Anything else is left exactly as it stands, including a malformed or unknown entity: … and a bare & both come back unchanged. Decoding is a single pass, so an entity produced BY the decoding is not decoded again — which is the property that makes < round-trip to the literal < the document meant.

string

string

import { decodeHtmlEntities } from "@alexkroman1/aai/utils";
decodeHtmlEntities("Fish & Chips"); // "Fish & Chips"
decodeHtmlEntities("it's here"); // "it's here"
// One pass, so an entity the decoding produced stays literal.
decodeHtmlEntities("<b>"); // "<b>"

fitToolResult(value, options?): unknown

value made to fit a tool result of maxChars characters of JSON without cutting through its structure — nulls and empties dropped, long strings clipped, the longest lists shortened from the end — plus a note saying so when anything was trimmed from a list. See the module doc for the order.

Answers value compacted when that alone fits; { result, note } when lists were shortened; { result_start, note } (the start of the JSON text) when nothing structural could make it fit. Never mutates value.

unknown

FitToolResultOptions

unknown

import { fitToolResult } from "@alexkroman1/aai/utils";
export function emailsForModel(emails: unknown[]): unknown {
return fitToolResult({ emails }, { maxChars: 12_000, maxString: 800 });
}

formatBytes(bytes): string

A byte count at the scale a person reads it: "17.7 MB", "110 KB", "512 B".

The unit is the largest one the value reaches, stepping by 1024 (B, KB, MB, GB, TB). Bytes and kilobytes are printed as whole numbers, because a tenth of a kilobyte is noise in a sentence; megabytes and up carry exactly one decimal, including a trailing zero ("2.0 MB"), so a column of them aligns and a size that grew from 2.04 to 2.4 does not read as unchanged.

Rounding that carries into the next unit is PROMOTED rather than printed: 1,048,000 bytes is "1.0 MB", never "1024 KB".

A byte count is never negative and never NaN, so both are reported as "0 B" rather than propagating into a sentence a caller shows a person — this runs on the narration path, where the alternative is "-0.0 MB" in a progress line.

number

string

import { formatBytes } from "@alexkroman1/aai/utils";
formatBytes(0); // "0 B"
formatBytes(112_640); // "110 KB"
formatBytes(18_559_795); // "17.7 MB"

formatDuration(ms): string

A duration as a clock reading: "4:09" under an hour, "1:04:09" over one.

Seconds are always two digits, minutes are two digits only once an hours field exists, and the hours field is omitted when it is zero rather than padded — so a two-minute clip reads "2:26" and only a long recording grows a field. Input is milliseconds, rounded to the nearest second.

The hours field is why this is shared. A m:ss formatter is four lines and looks finished, so every copy of it in this repo was written that way and every one of them printed a 64-minute run as "64:09". That is not a cosmetic difference: 64:09 reads as sixty-four minutes to a person who knows the format and as an error to everyone else, and the same run’s other copy said 1:04:09.

Negative and non-finite inputs are "0:00" — a duration is an elapsed time, and a caller subtracting two clock readings across a resume should not print "-1:-30" into a progress line.

number

string

import { formatDuration } from "@alexkroman1/aai/utils";
formatDuration(0); // "0:00"
formatDuration(249_000); // "4:09"
formatDuration(3_849_000); // "1:04:09"

formatMoney(amount, symbol?): string

$1,234.00 — an amount of money, grouped in threes and always to the cent.

symbol is a PREFIX and defaults to "$"; pass another ("€", "£") to change the glyph. It does not change the SHAPE, which is fixed: this is not a localization seam, for the reason the module doc gives. An agent that owes a caller 1.234,56 € formats it itself.

Always two decimal places, because the alternative drifts: a bare toLocaleString renders $1,234 for a round number and $1,234.5 for a change of fifty cents, so a price list rendered through it does not line up and a total read aloud sounds like a different kind of number than the parts that made it. Rounding is toFixed’s.

The sign LEADS (-$4.99), which is how a refund is written. An amount that rounds to zero has no sign, so a rounding error just under zero prints $0.00 rather than -$0.00. Non-finite is $0.00, matching formatBytes and formatDuration.

number

string

string

import { formatMoney } from "@alexkroman1/aai/utils";
formatMoney(0); // "$0.00"
formatMoney(17.5); // "$17.50"
formatMoney(2_292.371); // "$2,292.37"
formatMoney(-4.99); // "-$4.99"
formatMoney(1_234, "€"); // "€1,234.00"

jsonClient(options): JsonClient

Declare a JSON REST API once, and call it from tools, routes and steps.

JsonClientOptions

JsonClient

import { requireEnv } from "@alexkroman1/aai";
import { HttpError, jsonClient } from "@alexkroman1/aai/utils";
const mem0 = jsonClient({
baseUrl: "https://api.mem0.ai/v1",
headers: (env) => ({ authorization: `Token ${requireEnv({ env }, "MEM0_API_KEY")}` }),
label: "mem0",
});
export async function forget(ctx: { env: Record<string, string> }, id: string) {
try {
await mem0(ctx, "DELETE", `/memories/${encodeURIComponent(id)}/`);
return true;
} catch (err) {
if (err instanceof HttpError && err.status === 404) return false;
throw err;
}
}

normalizePhone(raw, options?): string | undefined

raw as an E.164 number ("+15125550123"), or undefined when it is not one. Spaces, dashes, dots and parentheses are formatting and are stripped. With defaultCountry, a number said without its country code is read in that country’s plan.

string

NormalizePhoneOptions

string | undefined

import { normalizePhone } from "@alexkroman1/aai/utils";
normalizePhone("+44 20 7946 0958"); // "+442079460958"
normalizePhone("(512) 555-0123"); // undefined — no country code, no guess
normalizePhone("(512) 555-0123", { defaultCountry: "US" }); // "+15125550123"

plural(n, one, many?): string

The right form of an English noun for a count: plural(1, "risk") is "risk", plural(2, "risk") is "risks".

many defaults to one + "s"; pass it for a noun that does not take a bare -s (plural(n, "entry", "entries"), plural(n, "person", "people")).

It returns the WORD, not the count, because the count almost always needs its own formatting on the way into the sentence — a formatDuration, a thousands separator, or a word ("no risks"). The call site writes `${n} ${plural(n, "risk")}`, which is the same shape as the seventeen inline `${n === 1 ? "" : "s"}` this replaces, minus the chance of pluralizing off a different variable than the one being printed — which is exactly the bug that idiom hides, since both halves read as noise.

Only exactly 1 takes the singular. Zero is plural ("0 risks"), which is English, and so is a negative or fractional count. Non-localized by construction: a language with more than two forms needs a different function, not an option on this one.

number

string

string

string

import { plural } from "@alexkroman1/aai/utils";
const risks = 3;
`Found ${risks} ${plural(risks, "risk")}.`; // "Found 3 risks."
`Read ${1} ${plural(1, "entry", "entries")}.`; // "Read 1 entry."

roundMoney(amount): number

An amount snapped to whole cents — roundMoney(0.1 + 0.2) is 0.3.

Money in a float is money in a type that cannot represent a cent: 0.1 + 0.2 is 0.30000000000000004, and a gift-card balance compared for equality against a price difference is then a coin toss. Every arithmetic result that is going to be COMPARED, summed into a running total, or stored goes through here.

The alternative is counting in integer cents end to end, which is stricter and is what a ledger should do. This is for the common case a template actually has — dollars in a float, arriving that way from a catalog — where the fix is to round at each step rather than to re-unit the whole domain. formatMoney rounds for DISPLAY and does not change the value, so a total assembled without this can print $0.30 and still fail === 0.3.

It rounds through toFixed(2), not Math.round(n * 100) / 100, and the two are not the same function. 2.675 * 100 is 267.49999999999994, so the multiply-and-round spelling — the one three templates each wrote — answers 2.68 where toFixed answers 2.67. Either is a defensible rounding of a value that is not really 2.675; what is not defensible is a total that compares as 2.68 and PRINTS as $2.67, which is what you get when the rounding here and formatMoney’s disagree. One basis, so they cannot.

Non-finite passes through unchanged: there is no nearest cent to NaN, and quietly answering 0 would hide the arithmetic that produced it.

number

number

import { roundMoney } from "@alexkroman1/aai/utils";
0.1 + 0.2; // 0.30000000000000004
roundMoney(0.1 + 0.2); // 0.3
roundMoney(19.995); // 20

spokenErrorReason(err, options?): string

Turn a failure into a short reason a person can hear: its FIRST sentence, with credentials redacted and URLs removed, capped at about one spoken sentence.

What a workflow’s failure announcement says after “Sorry, I couldn’t finish that:”. Redacts key=/token=/secret=/password=/sig= values (in a query string or bare), Bearer/Basic credentials and vendor-shaped keys (sk-…), drops URLs entirely, and answers "something went wrong" when nothing sayable is left. Never throws, whatever it is handed.

unknown

Anything thrown — an Error, a string, an object.

SpokenErrorReasonOptions

max caps the length (default 160).

string

A trimmed, non-empty reason.

import { spokenErrorReason } from "@alexkroman1/aai/utils";
const why = spokenErrorReason(
new Error("Request to https://api.example.com/v1?key=abc123 failed with 403. Retry later."),
);
// "Request to failed with 403."
void why;

A refused request from a jsonClient: the HTTP status, a message of the form "<label> <status>: <what the service said>", and the parsed body (the raw text when it was not JSON; absent when empty).

Branch on status (err instanceof HttpError && err.status === 404), never on the message’s wording.

  • Error

new HttpError(status, message, body?): HttpError

number

string

unknown

HttpError

Error.constructor

readonly optional body?: unknown

The response body — parsed JSON, else the raw text; absent when empty.

readonly status: number

The HTTP status the service answered.

Input

What fitToolResult takes.

optional hint?: string

Appended to the note on a trimmed answer — where the rest can be had: "Use the workbench to go through all of them.".

optional maxChars?: number

Longest the serialized answer may be, in characters. Default MAX_TOOL_RESULT_CHARS.

optional maxString?: number

Longest any one string may be; longer ones are clipped with …. Default: no clip.


Input

The context a JsonClient call reads: a tool’s ctx, a route’s ctx, or { env: … } built from stepEnv in a workflow step.

env: Readonly<Partial<Record<string, string>>>

The agent’s environment — what headers and baseUrl read.

optional signal?: AbortSignal

Cancels the request: pass the tool’s or route’s own signal.


Input

What jsonClient takes.

baseUrl: string | ((env) => string)

The API’s base URL, e.g. "https://api.mem0.ai/v1"; a request’s path is appended to it. A FUNCTION reads it from the env on each call (a self-hosted service’s URL is configuration). Trailing slashes are dropped.

optional errorMessage?: (body) => string | undefined

The service’s own sentence from a refused body — parsed JSON when it parsed, else the raw text. Answer undefined to fall back to a preview of the raw body. E.g. (body) => isRecord(body) && isRecord(body.error) ? String(body.error.message) : undefined.

unknown

string | undefined

optional fetch?: {(input, init?): Promise<Response>; (input, init?): Promise<Response>; }

For TESTS: the fetch to call. Defaults to the global one.

(input, init?): Promise<Response>

MDN Reference

RequestInfo | URL

RequestInit

Promise<Response>

(input, init?): Promise<Response>

MDN Reference

string | Request | URL

RequestInit

Promise<Response>

optional headers?: Record<string, string> | ((env) => Record<string, string>)

Headers for every request, typically the credential: (env) => ({ authorization: \Token ${requireEnv({ env }, “MEM0_API_KEY”)}` }). Read per call, so a secret set after start is picked up and a missing one throws where the call is made. Merged over the JSON defaults (content-type/accept: application/json`).

label: string

Names the service in every failure message: "mem0" → "mem0 404: …".


Input

Per-call extras for a JsonClient request.

optional headers?: Record<string, string>

Headers for this request only, merged over the client’s.


Input

What normalizePhone takes.

optional defaultCountry?: "US" | "CA"

The country a number WITHOUT a + is assumed to be in. "US" and "CA" (North America, +1): ten digits, or eleven starting with 1. Unset, a number without its + is refused rather than guessed at.

JsonClient = <T>(ctx, method, path, body?, init?) => Promise<T>

One call to the API a jsonClient describes. Resolves to the parsed JSON body, or {} for an empty one (a 204); rejects with an HttpError for a non-2xx, or a 2xx whose body is not JSON.

T = unknown

JsonClientContext

string

string

unknown

JsonRequestInit

Promise<T>


SpokenErrorReasonOptions = object

Input

Options for spokenErrorReason.

optional max?: number

The most characters the reason may run to, cut on a word boundary where there is one. Defaults to 160 — about one spoken sentence.

Re-exports createKeyedLock


Re-exports errorDetail


Re-exports errorMessage


Re-exports failable


Re-exports INSTRUCTION_KEY


Re-exports instructionOf


Re-exports isRecord


Re-exports isToolFailure


Re-exports KeyedLock


Re-exports KeyedLockOptions


Re-exports KeyedLockTimeoutError


Re-exports omitUndefined


Re-exports orFail


Re-exports pushCapped


Re-exports responseErrorMessage


Re-exports safeJsonParse


Re-exports stripInstruction


Re-exports toolFailure


Re-exports ToolFailure


Re-exports toolRefusal


Re-exports ToolRefusal


Re-exports ToolRefusalReason


Re-exports withInstruction


Re-exports WithInstruction


Re-exports withLock