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.
Functions
Section titled “Functions”countWords()
Section titled “countWords()”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.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”number
Example
Section titled “Example”import { countWords } from "@alexkroman1/aai/utils";
countWords(" hello there\nfriend "); // 3countWords(" "); // 0decodeHtmlEntities()
Section titled “decodeHtmlEntities()”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 &lt; round-trip to the literal <
the document meant.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”string
Example
Section titled “Example”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("&lt;b&gt;"); // "<b>"formatBytes()
Section titled “formatBytes()”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.
Parameters
Section titled “Parameters”number
Returns
Section titled “Returns”string
Example
Section titled “Example”import { formatBytes } from "@alexkroman1/aai/utils";
formatBytes(0); // "0 B"formatBytes(112_640); // "110 KB"formatBytes(18_559_795); // "17.7 MB"formatDuration()
Section titled “formatDuration()”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.
Parameters
Section titled “Parameters”number
Returns
Section titled “Returns”string
Example
Section titled “Example”import { formatDuration } from "@alexkroman1/aai/utils";
formatDuration(0); // "0:00"formatDuration(249_000); // "4:09"formatDuration(3_849_000); // "1:04:09"formatMoney()
Section titled “formatMoney()”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.
Parameters
Section titled “Parameters”amount
Section titled “amount”number
symbol?
Section titled “symbol?”string
Returns
Section titled “Returns”string
Example
Section titled “Example”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()
Section titled “plural()”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.
Parameters
Section titled “Parameters”number
string
string
Returns
Section titled “Returns”string
Example
Section titled “Example”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()
Section titled “roundMoney()”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.
Parameters
Section titled “Parameters”amount
Section titled “amount”number
Returns
Section titled “Returns”number
Example
Section titled “Example”import { roundMoney } from "@alexkroman1/aai/utils";
0.1 + 0.2; // 0.30000000000000004roundMoney(0.1 + 0.2); // 0.3roundMoney(19.995); // 20References
Section titled “References”createKeyedLock
Section titled “createKeyedLock”Re-exports createKeyedLock
errorDetail
Section titled “errorDetail”Re-exports errorDetail
errorMessage
Section titled “errorMessage”Re-exports errorMessage
failable
Section titled “failable”Re-exports failable
isRecord
Section titled “isRecord”Re-exports isRecord
isToolFailure
Section titled “isToolFailure”Re-exports isToolFailure
KeyedLock
Section titled “KeyedLock”Re-exports KeyedLock
KeyedLockOptions
Section titled “KeyedLockOptions”Re-exports KeyedLockOptions
KeyedLockTimeoutError
Section titled “KeyedLockTimeoutError”Re-exports KeyedLockTimeoutError
omitUndefined
Section titled “omitUndefined”Re-exports omitUndefined
orFail
Section titled “orFail”Re-exports orFail
pushCapped
Section titled “pushCapped”Re-exports pushCapped
responseErrorMessage
Section titled “responseErrorMessage”Re-exports responseErrorMessage
safeJsonParse
Section titled “safeJsonParse”Re-exports safeJsonParse
toolFailure
Section titled “toolFailure”Re-exports toolFailure
ToolFailure
Section titled “ToolFailure”Re-exports ToolFailure
withLock
Section titled “withLock”Re-exports withLock