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>"

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"

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

Re-exports createKeyedLock


Re-exports errorDetail


Re-exports errorMessage


Re-exports failable


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 toolFailure


Re-exports ToolFailure


Re-exports withLock