# ts-utils

TypeScript utilities - logger, currency, offensive programming, file operations, environment management, and more. The sibling of [swift-utils](https://github.com/adriangalilea/swift-utils), [py-utils](https://github.com/adriangalilea/py-utils), and [go-utils](https://github.com/adriangalilea/go-utils). The logging contract the three share, and the harness that proves they write the same event stream, live in [utils](https://github.com/adriangalilea/utils).

## Product metrics

```ts
import { createClient } from "@libsql/client"
import { defineMetrics } from "@adriangalilea/utils/metrics"
import { libsqlDriver } from "@adriangalilea/utils/metrics/libsql"
import { metricsStore } from "@adriangalilea/utils/metrics/sqlite"

const driver = libsqlDriver(createClient({ url: process.env.METRICS_DATABASE_URL!, authToken: process.env.METRICS_AUTH_TOKEN }))
const metrics = defineMetrics({
  installCopy: { kind: "counter", label: "install command copies", dimensions: { component: ["chat", "glass"] } },
}, { store: metricsStore(driver, "my-project") })

await metrics.installCopy.bump({ dimensions: { component: "chat" } })
```

One SQLite dialect, many stores. `metrics/sqlite` holds the schema, the store and
the readers over a `MetricsDriver`: two verbs, `query(statement)` for rows and
`transact(statements)` for an all-or-nothing write batch. `metrics/libsql` wraps a
Turso / libSQL client, `metrics/d1` wraps a Cloudflare D1 binding (`d1Driver(env.DB)`),
each with the driver's native calls and structural types, so no database client is
bundled. Any other SQLite driver implements the two verbs in a few lines (the test
does it over `node:sqlite`). Readers need only `query`, a `MetricsReader`:
`metrics/d1-rest` reads a D1 database over Cloudflare's REST API from a process with
no binding (`d1RestReader({ accountId, databaseId, token })`). A store that does not
speak SQLite implements the `MetricsStore` contract, `declare(schema)` and
`write(measurement)`, and its own readers returning the `metrics/report` types.

**The store describes itself.** `declare` runs once per process before the first
sample and carries the whole schema: kinds, labels, help, units, per-user flags and
overlaps. Every later sample is one small transaction. A reader therefore never needs
the declaring code: `readSchema(reader, project)` returns what a project declared,
and `readReport(reader, { project, days, includeToday? })` returns everything a panel
renders in one call, `{ project, window, schema, daily, audience }`. A changed kind or
unit is refused at declaration and every sample of that process fails loudly; a store
that was briefly away is asked again by the next sample.

Provision the exported `METRICS_SCHEMA` once before collecting. `readMetrics(reader,
{ from, to, project? })` returns daily rows with labels and dimensions, using inclusive
UTC dates. `readAudience(reader, { from, to, project }, schema)` answers who did it
from the opt-in per-user rows: one row per declared per-user metric (zeros when nobody
touched it), with distinct actors, actors with more than one sample on a single day,
actors seen on more than one day, then each declared overlap as `fromUsers` and
`bothUsers`. Readers propagate database errors rather than presenting missing data
as zero.
`summarizeMetrics(rows)` from `metrics/report` produces one generic report shape;
`renderMetrics(rows, { daily? })` from `metrics/cli` renders it with the existing
ANSI-aware `cli.table` helpers. Both discover every recorded metric from its
declaration metadata. No product-specific metric list belongs in a renderer.

For period comparisons, `metricWindows(days, { now?, includeToday? })` from
`metrics/report` returns adjacent equal-length `current` and `previous` UTC ranges.
By default both contain completed days; `includeToday` marks the current window
`partial`. Fetch those ranges through your adapter, then call
`compareMetrics(currentRows, previousRows)` and
`renderMetricComparison(comparisons)` from `metrics/cli`. Comparisons include
disappearing series, count changes and weighted timing-average changes. Percent
change is null without a nonzero baseline; missing timing samples stay null.
Changing a metric's kind or unit across the inputs is rejected. Missing observations
are not proof that collection was running: deltas compare recorded data only.
Use a new key for a new meaning; labels, help and the per-user flag can be edited.

Measurements validate finite nonnegative samples, positive integer counts, and
declared dimension values. Calls resolve even when validation or storage fails;
`onError(error, key)` observes failures (the default logs a warning without actor
data). Await calls or attach them to the runtime's request lifetime (`after` in
Next.js, `waitUntil` in Workers). Unattached promises are not reliable delivery.
Counters are best-effort observations, not exactly-once ledgers; retries can count
twice. Money and successful-install accounting belong elsewhere.

`perUser: true` accepts an optional string or numeric `user` for daily actor rows.
Enable it only with a deletion and retention policy; no identity is inferred.
`overlaps: [["a", "b"]]` declares unordered audience overlap, not an ordered funnel.
Daily totals support counts and averages, not percentile latency or event ordering.

## Visits

A site's own visits live in the [ui registry](https://ui.adriangalilea.com/visits) as one family: `page-sensor` (the page), `visits` (the route, the store, the readers) and `user-flows` (the views), installed into the site as code it owns (`npx shadcn add @ag/page-sensor` brings `visits`). They build on this package's logger (the event stream the pages write) and its metrics drivers (the store).

#### Migrating from 9.x

`@adriangalilea/utils/visits` is gone. Install the registry's `visits` and import from your copy (`@/lib/visits`): the same functions, the same tables and rows.


## Installation

```bash
pnpm add @adriangalilea/utils
```

## Usage

### Logger

A program's logs and traces are one stream of events. A line is an event; a span is a piece of work written as a start and an end; every line written through a span carries its id. go-utils and py-utils write the same stream byte for byte, so their output can be read, merged and traced together. The contract, its JSON schema and the harness that runs all three live in [utils](https://github.com/adriangalilea/utils).

```typescript
import { log } from '@adriangalilea/utils/log'

log.info('ready')
log.warn('retrying', { attempt: 2 })
log.error('charge failed', { err })
```

Every verb takes a message, then optional fields. The message says what happened, the fields say with what. Fields are a plain object; anything else (an `Error`, a string, extra arguments) panics.

| verb | level | human |
|---|---|---|
| `error`, `fail` | error | `⨯` |
| `warn` | warn | `⚠` |
| `warnOnce` | warn, once per message and scope (bounded memory) | `⚠` |
| `info` | info | |
| `success` | info | `✓` |
| `wait` | info | `○` |
| `ready` | info | `▶` |
| `step` | info | `•`, indented |
| `debug` | debug | `◦` |
| `trace` | trace | `»` |

**Scopes.** `log.scope('api')` returns a child logger whose events carry `["api"]`; `log.scope('api').scope('auth')` carries `["api", "auth"]`.

**Spans.** A span is a logger: what is written through it belongs to it, and nothing else does (there is no ambient context, a function that logs inside a span takes the span). `span` returns what the callback returns, a Promise when it is async; a throw ends the span failed, with the error in `fields.err`, and is rethrown.

```typescript
await log.span('sync', { repo }, async (s) => {
  s.info('cloned', { files: 3 })
  await s.span('build', async (c) => { c.step('compiling') })
  s.set({ pushed: true })          // carried on the span's end event
})

// Work that does not fit a block: open it, end it once (twice panics).
const up = log.begin('upload', { size })
stream.on('end', () => up.end())
stream.on('error', (err) => up.end(err))
```

`s.traceId` is the id of the trace it belongs to, the `trace_id` its events carry: what a reader of the stream files and finds it under.

**Traces cross processes explicitly.** `s.traceparent()` is the W3C value to hand on, in an HTTP `traceparent` header or the `TRACEPARENT` env of a child process. `log.join(req.headers.get('traceparent'))` returns a logger whose spans are children of that remote span; `null` or `undefined` returns the logger itself, and a malformed value is ignored with one warn per distinct value. A process started with `TRACEPARENT` set joins it for every root span, with no code.

**Levels.** `silent < error < warn < info < debug < trace`, default `info`. A scoped event reads its threshold live from the most specific variable set: `["api", "auth"]` reads `API_AUTH_LOG_LEVEL`, then `API_LOG_LEVEL`, then `LOG_LEVEL`, then `setLogLevel()`, then `info`. `GZIP_LOG_LEVEL=silent` shuts one subsystem up without touching code. Whether a span is shown is decided once, when it begins; a hidden span writes no end, but a failure is never lost to that: a hidden span that fails writes one `fail` line (its name, its fields with `err` and `dur_ms`) as a line of the nearest shown span. What it holds (lines, child spans, `traceparent()`) attaches to the nearest shown span, so a viewer never meets a missing parent. A remote span joined through `join` or `TRACEPARENT` counts as shown. With no shown ancestor, a hidden span's lines carry no ids, as lines outside any span.

**Renderings.** `json` is one event per line on stderr, keys in the contract's order, fields sorted at every depth, errors as `{type, message, stack, cause, …}`:

```json
{"time":"2026-10-12T12:20:39.123Z","level":"warn","verb":"warn","msg":"retrying","service":"checkout","scope":["payments"],"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","fields":{"attempt":2}}
```

`human` is symbols, colour (TTY, `NO_COLOR`, `FORCE_COLOR`), fields dim as `key=value`, a field holding several lines under its line as written, an error's stack under its line (once: a span failing with the error a span inside it already failed with shows the message only; json writes it on both ends), and spans with what they hold indented:

```
 ○ sync repo=ui
     cloned files=3
   ○ build
       • compiling
   ✓ build 51ms
 ✓ sync 51ms pushed=true
```

Node, Bun and Deno write every event through `console.error` (stdout stays data, and a `cli` live region keeps lines above it); browsers and Workers use the level's console method, so Workers Logs files each event at its level. A Worker is recognised by its user agent, so `nodejs_compat`'s `process` does not pass it off as Node; its `LOG_SERVICE` and other vars are read from that `process.env`.

**Configuration.** Each variable has a setter. The setter is the program's default and the variable, read live, overrides it. Values match without regard to case, an empty variable is unset, and an unknown value panics.

| variable | setter | values | default |
|---|---|---|---|
| `LOG_FORMAT` | `setLogFormat` | `human`, `json` | Node: human when stderr is a TTY, else json. Browser: human. Worker: json |
| `LOG_TIME` | `setLogTime` | `1`, `0` | off. Puts a dim local `HH:MM:SS` before human lines; json always carries `time` |
| `LOG_CONSOLE` | `setLogConsole` | `1`, `0` | on. Off, events are not written to the console (stderr, a browser's console) and only ship: a page that ships its stream shows a visitor nothing |
| `LOG_LEVEL`, `{SCOPE}_LOG_LEVEL` | `setLogLevel` | the levels | `info` |
| `LOG_SERVICE` | `setLogService` | a name | the entry script's name without extension. A Worker or browser has none: set it, or the first json event panics |
| `LOG_SHIP` | `setLogShip` | an http(s) URL | unset. Every event that passes its threshold is also POSTed there (Shipping) |
| `LOG_SHIP_TOKEN` | `setLogShipToken` | a token | unset. Sent as `Authorization: Bearer` with each batch; a browser sends none, and `setLogShipToken` in one panics |

**Shipping.** With `LOG_SHIP` set, the logger also POSTs its events to that URL, so a process ships its stream with no code:

```sh
LOG_SHIP=https://logs.example.com/ingest LOG_SHIP_TOKEN=… node server.js
```

What ships is the stream's json line, byte for byte, whatever the console renders (human at a terminal included). A batch is lines joined by `\n`, `Content-Type: text/plain;charset=utf-8`, at most 64 KB and 50 events. It leaves 2s after its first event, at 50 events, when a span opened outside any local span ends (a root span, or a request joined from its caller: its whole trace leaves together), before a Node or Bun process exits on its own (`beforeExit`), and in a browser on `visibilitychange: hidden` and `pagehide`; whatever a hidden page sends goes through `sendBeacon`, the one request a browser finishes after the page is gone. `flushLog()` sends what waits and resolves when nothing is in flight: a Worker hands it to `ctx.waitUntil(flushLog())`, a serverless function to its runtime's `after`, and a process that calls `process.exit()` awaits it first (nothing else runs after `exit`). Shipping never throws and never blocks a write: a refused or lost batch is one `warnOnce` under scope `ship`, an event over 64 KB stays on the console and warns once. The wire and the collector's answers are the contract's, in [utils](https://github.com/adriangalilea/utils#shipping).

**A page's trace on a Next.js server** (`@adriangalilea/utils/log/next`, `next` an optional peer). A page that writes the stream (utils README, "Web pages") puts its `traceparent` on every same-origin request; these wrappers make the server's work a child of that request, in the page's trace, and hand `flushLog` to Next's `after` so a frozen instance holds nothing unsent. A request with no `traceparent` is a root span of its own.

```typescript
import { traced, tracedAction } from '@adriangalilea/utils/log/next'

export const POST = traced('POST /api/vote', async (s, request) => {
  await s.span('turso insert vote', () => db.execute(insert))
  return Response.json({ ok: true })
})

// in a "use server" file
export const vote = tracedAction('vote', async (s, id: string) => { … })
```

**Reading the stream.** `LogEvent` is the wire shape as a type (a line, a span start, an ok end or a failed end, discriminated by `phase` and `status`), and `parseEvent(line)` reads one line back: an event, `null` for a line that is not one (pass it through), or a `Panic` for a JSON object with a `verb` key that breaks the shape.

```typescript
import { parseEvent, type LogEvent } from '@adriangalilea/utils/log'

const e = parseEvent(line)
if (e?.phase === 'end') e.dur_ms
```

`humanReader({ clock? })` renders a stream the way the `human` format shows it live: spans at their depth, lines under the span that wrote them, durations, an error's stack once. The depth comes from the stream's own `parent_id`s, so a stored file, or a stream another process shipped, reads like that process's console.

```typescript
const show = humanReader({ clock: true })
for (const line of text.split('\n')) {
  const e = parseEvent(line)
  if (e) console.log(show(e))
}
```

`breakdown(events)` says where a trace's time went: each span's own time (its duration, less the time its children cover) counts for its `kind` field, or the nearest enclosing span's, else `other`. Name what a span does where it begins (`span('look', { kind: 'perception' })`) and everything inside counts for it until a span says otherwise.

```typescript
breakdown(events) // { wall_ms: 26800, by: { inference: 18600, input: 4600, wait: 2300, other: 900, perception: 300 } }
```

#### Migrating from 8.x

- Messages are one string plus fields: `log.warn('retry', n, err)` becomes `log.warn('retry', { attempt: n, err })`, and `log.scope(e.source).error(e.operation, e.toJSON())` becomes `log.scope(e.source).error(e.operation, { err: e })`.
- Import `log` and call its verbs: the bare `info`, `warn`, `scope`, … exports are gone.
- `time`/`timeEnd` are gone: wrap the work in `log.span(name, fn)`, which writes its duration.
- `LOG_FORMAT=record` and `setLogFormat('record')` are gone: `json` replaces the record line, and `logs` from go-utils renders a file back into a greppable line.
- The environment now wins over `setLogFormat` and `setLogTime`, and `LOG_TIME` is off by default.
- A Worker that writes json must call `setLogService(name)`.
- `SourcedError.message` is the message given, without the `[source:operation]` prefix.

#### Migrating from 9.0

Library code writes every diagnostic through the logger (`pnpm test:raw-writes` refuses a raw `console.*`), so the per-module switches are gone and the environment does their job:

- `gracefulStart`'s `log` option: its lines are scope `["bot", "kit"]`, `BOT_KIT_LOG_LEVEL=silent` quiets them.
- `coalesceLongMessages`'s `log` option: `BOT_COALESCE_LOG_LEVEL=debug`.
- `kev.debug = true`: `KEV_LOG_LEVEL=debug`. The trace names each lookup's source, never its value.
- Bot modules log under `["bot", "<module>"]` (was `["bot/<module>"]`), so `BOT_LOG_LEVEL` governs them all; the per-module variable names are unchanged.

### Currency

Currency utilities with comprehensive crypto support (500+ symbols):

```typescript
import { currency, isCrypto, isStablecoin, isFiat, getSymbol, getOptimalDecimals } from '@adriangalilea/utils'

// Check currency types
isCrypto('BTC')  // true
isCrypto('XBT')  // true (alternative for BTC)
isCrypto('WBTC')  // true (wrapped tokens detected)
isStablecoin('USDT')  // true
isFiat('USD')  // true

// Get currency symbols
getSymbol('BTC')  // '₿'
getSymbol('ETH')  // 'Ξ'
getSymbol('USD')  // '$'

// Get optimal decimal places based on value
getOptimalDecimals(0.00001234, 'BTC')  // 10
getOptimalDecimals(1234.56, 'USD')  // 2
getOptimalDecimals(0.123, 'ETH')  // 6

// Percentage calculations
currency.percentageOf(25, 100)  // 25
currency.percentageChange(100, 150)  // 50
currency.percentageDiff(100, 150)  // 40

// Basis points
currency.basisPointsToPercent(100)  // 1
currency.percentToBasisPoints(1)  // 100
currency.formatBasisPoints(50)  // "50 bps"
```

### Format

Pure number formatting — named exports, tree-shakeable, zero currency baggage
(money formatting lives in the currency module, which owns the symbol/decimals
knowledge and its crypto-symbol dataset):

Every Intl call pins `en` — SSR apps hydrate identically on server and client
(no React #418 from a runtime-default locale). Every formatter accepts
`null | undefined` and renders `"—"`, so DB fields pass straight through.

```typescript
import { compact, percentage, fixed, withCommas, bytes, bitsPerSec } from '@adriangalilea/utils/format'

withCommas(1234567)     // "1,234,567" (bigint-safe)
withCommas(1234.567, 2) // "1,234.57"
compact(1234567)        // "1.2M"
compact(1234)           // "1.2K"
percentage(12.3456)     // "12.3%" (smart decimals)
percentage(0.05)        // "0.05%"
percentage(123.456)     // "123%"
percentage(2.41, { sign: true })    // "+2.4%" (deltas: 24h change, diffs)
percentage(2.4126, { decimals: 2 }) // "2.41%" (fixed decimals)
fixed(1.234)            // "1.23" (plain toFixed, no grouping)
bytes(12.7e12)          // "12.7 TB" (decimal SI, like drive specs / df -h)
bitsPerSec(2.5e9)       // "2.5 Gbps"
percentage(null)        // "—" (every formatter)
```

Money lives in `currency`, which is split by dependency weight so the barrel
tree-shakes at file granularity: the 133KB crypto-symbol dataset is imported
only by its classify module, so `import { usd } from '…/currency'` ships
dollars, not 13k tickers.

```typescript
import { usd, usdIntlOptions, btc, money } from '@adriangalilea/utils/currency'

usd(1234.56)                  // "$1,234.56" (grouped; dataset-free import)
usd(4.3387e-7)                // "$0.00000043" (sub-cent keeps 2 significant digits)
usd(0.43387, { decimals: 4 }) // "$0.4339" (policy override)
usd(60892283, { compact: true }) // "$60.9M" (market-cap scale)
btc(0.00001234)     // "0.000012340 ₿" (dataset via optimal decimals)
money(100, 'EUR')   // "€100.00"

// The decimals policy as Intl.NumberFormatOptions — for consumers that format
// internally (NumberFlow-style animated numbers, chart axes):
usdIntlOptions(0.43387) // { style: 'currency', currency: 'USD', minimumFractionDigits: 3, ... }
```

### ANSI primitives (`ansi`)

Raw escape codes, ALWAYS ON — the vocabulary under the TTY-aware layers. The
logger and `cli` palettes decide *whether* to color (TTY, `NO_COLOR`,
`FORCE_COLOR`); this module is for output whose consumer renders ANSI
regardless of what stdout is: Claude Code status lines, tmux status strings,
files a terminal will cat. 256-color first (`fg`/`bg` — stable across
terminals and multiplexers); `rgb` for known-truecolor consumers. Out-of-range
indices panic.

```typescript
import { fg, bg, rgb, BOLD, RESET, stripAnsi, ANSI_RE } from '@adriangalilea/utils/ansi'

`${BOLD}${fg(171)}Fable 5${RESET}`   // bold bright violet
fg(196)                              // red foreground
bg(238)                              // dark gray background
rgb(173, 127, 168)                   // 24-bit, when the consumer renders it
stripAnsi(styled)                    // the string minus its escapes
```

`cli`'s ANSI-aware `width()` / `clip()` measure strings styled with these
(they share `ANSI_RE`).

The semantic roles live here too, the one map of role to escape that `cli`'s
`ui` paints with: `ROLES` (`head`, `accent`, `muted`, `ok`, `warn`, `bad`,
`ref`) and `inRole(role, s)`, always on. A page that draws a CLI's output (a
terminal on a landing page) writes it in the same roles, so the two cannot
disagree on a colour.

```typescript
import { inRole } from '@adriangalilea/utils/ansi'

`${inRole('muted', 'code')} ${inRole('head', 'BCDF-GHJK')}`
```

### Time (`time`)

Humanized time — three questions, three functions: how long is a duration
(`span`), how long ago was an instant (`ago`, raw-duration twin `since`), how
long until an instant (`until`). `ago` degrades by distance instead of
stacking units: fresh instants stay relative, older ones snap to the clock the
reader would check. Accepts `Date | ISO string | epoch ms | null | undefined`
(null renders `"—"`); epoch numbers are milliseconds only, by design — a
seconds heuristic would silently shift dates near the boundary.

```typescript
import { span, since, ago, until } from '@adriangalilea/utils/time'

span(6_000_000)                    // "1h 40m"  (also "45s", "47m", "2d 3h")
since(sessionStart)                // "47m" — elapsed, no "ago"
ago(twoMinutesAgo)                 // "2m ago"  ("now" under a minute)
ago(fiveHoursAgo)                  // "5h 12m ago"  (relative up to 6h)
ago(thisMorning)                   // "10:30"       (clock time, today)
ago(lastNight)                     // "yst 22:15"
ago(fourDaysAgo)                   // "Mon 09:00"   (within a week)
ago(lastMonth)                     // "07/28"
until(weeklyReset)                 // "in 3d 13h"   ("now" if passed)
ago(null)                          // "—" (every function)
```

### URL (`url`)

URLs, from user-typed text to cache identity. The whole funnel — *is there a
URL? which resource? whose content? what identity?* — answered once, on one
object. Each concern rides its state of the art instead of hand-rolls:
detection is [linkifyjs](https://linkify.js.org) (scanner-based, TLD-aware);
parsing is the WHATWG URL API, never regexes; the knowledge of WHICH params
are tracking is vendored from
[@protontech/tidy-url](https://www.npmjs.com/package/@protontech/tidy-url)
(Proton's maintained fork of DrKain/tidy-url, MIT; refresh with
`pnpm update-url-rules`) plus a small tested overlay; per-site content
identity is an adapter registry (YouTube built in). Global rules only for
unambiguous trackers (utm_*, fbclid, gclid, …); ambiguous names stay per-host —
`si` is junk on YouTube/Spotify but `ref` on GitHub names a branch and
survives.

```typescript
import { urlsIn, urlOf, cleanUrl, urlKey, hostOf, hostMatches,
         youtubeVideoId, youtubeUrl, youtubeThumbnailUrl } from '@adriangalilea/utils/url'

// Every http(s) URL in free text, in order, fully resolved:
urlsIn('watch youtu.be/dQw4w9WgXcQ?si=junk and https://a.com/x?utm_source=t')
// [{ raw: 'youtu.be/dQw4w9WgXcQ?si=junk', start: 6, end: 34, hadScheme: false,
//    href: 'https://youtu.be/dQw4w9WgXcQ',          // cleaned — fetch/share this
//    host: 'youtu.be', site: 'youtube', id: 'dQw4w9WgXcQ',
//    key:  'youtube:dQw4w9WgXcQ' },                  // THE cache/dedupe identity
//  { …, href: 'https://a.com/x', site: null, id: null, key: 'a.com/x' }]

// One pasted token → one resolved Url (or null). Punctuation, brackets,
// emails-are-not-URLs: the scanner's problem, not yours.
urlOf('<https://example.com/a?utm_source=x>,')?.href  // 'https://example.com/a'
urlOf('example.com/a', { requireScheme: true })       // null (bare domains opt-out)

// `key` is the point: every spelling of one resource collides.
// Recognized sites collapse to their native content id —
urlKey('https://music.youtube.com/watch?v=dQw4w9WgXcQ&si=x')  // 'youtube:dQw4w9WgXcQ'
urlKey('https://www.youtube.com/shorts/dQw4w9WgXcQ')          // 'youtube:dQw4w9WgXcQ'
// — everything else canonicalizes generically:
urlKey('https://www.theverge.com/2026/story/?utm_source=x#c') // 'theverge.com/2026/story'

// The SAME resource minus tracking — keeps scheme, www., order, fragment.
cleanUrl('https://www.youtube.com/watch?v=abc&t=120s&si=junk')
// 'https://www.youtube.com/watch?v=abc&t=120s'

// YouTube identity from ANY spelling (watch, youtu.be, shorts, live, embed,
// music., nocookie, redirect wrappers, bare 11-char id):
youtubeVideoId('https://www.youtube.com/attribution_link?u=%2Fwatch%3Fv%3DdQw4w9WgXcQ')
// 'dQw4w9WgXcQ'
youtubeUrl('dQw4w9WgXcQ')           // 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
youtubeThumbnailUrl('dQw4w9WgXcQ')  // 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg'

hostOf('https://www.theverge.com/a')            // 'theverge.com'
hostMatches('music.youtube.com', 'youtube.com') // true (RFC 6265 domain-match)
hostMatches('notyoutube.com', 'youtube.com')    // false

// App-specific junk rides along per call
urlKey(url, { strip: ['session_id'] })
```

Unparseable or non-http(s) input passes through unchanged — these are hygiene
functions over user-pasted text, not validators.

### CLI presentation (`cli`)

Terminal output: aligned tables, key/value blocks, trees, and a semantic color
palette. Distinct from `format` (universal value formatting) — this is
terminal-scoped. Colors come from the logger and auto-disable on non-TTY /
`NO_COLOR`, and alignment is **ANSI-aware** (padding uses visible width), so a
`table()` renders colored in a terminal and as plain aligned text in a pipe, log
file, or a bot's monospace block.

```typescript
import { table, kv, tree, indent, clip, ui } from '@adriangalilea/utils/cli'

// Semantic palette — use these, not raw colors, so intent stays consistent
ui.head('Name')   ui.accent('id')   ui.muted('note')
ui.ok('done')     ui.warn('!')      ui.bad('err')   ui.ref('#abc123')

// Aligned columns (auto width; per-column align; optional bolded header).
// Cells may be pre-colored — widths use visible length so they still line up.
table(
  [['Ada', ui.accent('ada@x.com'), ui.ref('42')],
   ['Bo',  ui.accent('bo@y.com'),  ui.ref('7')]],
  { head: ['name', 'email', 'msgs'], align: ['l', 'l', 'r'] },
)

// Key/value block (aligned keys) — for a detail view
kv([['name', 'Ada'], ['email', 'ada@x.com']], { indent: 2 })

// Nesting
tree('Ada', ['email: ada@x.com', 'phone: +1…'])   // labeled node + children
indent(block, 4)                                    // indent every line
clip('a very long value', 10)                       // "a very lo…" (ANSI-aware — styled input stays styled)
```

Run the demo: `FORCE_COLOR=1 pnpm tsx tests/cli-demo.ts`.

### Live output (`cli`) — pinned region, spinner, progress

A pinned, self-repainting region at the bottom of the terminal for progress UIs. You own the state; the region is a `render: () => string` repainted on a timer — a frame is just a string, so `table()` / `kv()` / `ui` and the widgets (`spin()`, `bar()`, `elapsed()`) compose inside it unchanged.

```typescript
import { live, spinner, spin, bar, elapsed, table, ui } from '@adriangalilea/utils/cli'

// The one-liner: animated while fn runs, persists "✓ label 1.2s" when done
await spinner('connecting to imap.gmail.com', () => adapter.connect())

// The general region: declarative multi-line progress
const region = live(() => table(accounts.map(a => [
  a.done === a.total ? ui.ok('✓') : spin(),
  a.alias,
  bar(a.done, a.total, 18),
  ui.muted(`${a.rate}/s`),
])))
// ...mutate your state; it repaints ~12.5fps (region.refresh() for instant)
region.done()   // final frame persists into scrollback (or .clear() to remove)
```

Patterns:

```typescript
// Relabel a spinner mid-flight — fn receives a setter
await spinner('connecting 0/4', async (set) => {
  for (const [i, a] of accounts.entries()) { await a.connect(); set(`connecting ${i + 1}/4`) }
})

// Streaming table — rows appear as they arrive, columns re-align retroactively
const rows: string[][] = []
const region = live(() => table(rows, { head: ['day', 'author', 'title'] }))
for await (const e of feed) { rows.push(renderRow(e)); region.refresh() }
region.done()   // full aligned table persists (a pipe gets exactly this, once)

// State-dependent bar color — style hook on the filled part
bar(done, total, 18, ui.warn)   // amber: throttled/cooling
```

What makes it hold up:

- **Logging never tears the UI, with no API to learn.** While a region is active, `console.log/warn/error` — and therefore the logger — are rerouted to print *above* the region (erase → write → repaint). Keep logging from anywhere, including third-party code. Boundary: only `console.*` is patched — a library writing raw to `process.stdout.write` bypasses the routing and can tear.
- **Non-TTY degrades to sane output.** In a pipe / CI / log file nothing animates: `done()` prints the final frame once, `spinner()` prints just its `✓ label 1.2s` line. Same calling code. Opt-in `heartbeat: ms` prints plain snapshots so long CI runs aren't silent.
- **Renders to stderr by default** — stdout stays clean for `--json` and pipes.
- **Crash-safe cursor**: hidden while painting, restored on done/clear, process exit, and signals — politely (if the app has its own SIGINT handler for graceful shutdown, it stays in charge).
- **Flicker-free**: synchronized-update escapes (`?2026`) make repaints atomic on modern terminals; lines are ANSI-aware clipped to the terminal width so the erase math never breaks.
- **Measurement is in terminal cells**: a ZWJ emoji / flag is one grapheme, and CJK characters and emoji take two cells, so `width()`/`clip()`/`table()` line up what a terminal shows (py-utils `cli` measures the same way). `clip()` never splits a wide character at the cut.
- One **region** at a time, by design (`assert`): two pinned regions can't share the bottom of one screen — compose into a single `render()`. A `spinner()` started *under* an active region is legitimate composition and degrades gracefully: its final line prints above the region.

Run the demo: `pnpm tsx tests/live-demo.ts` (and pipe it through `| cat` to see the non-TTY degradation).

### Offensive Programming

Fail loud, fail fast. Zero dependencies, works in Node, Deno, Bun, and browsers.

Two kinds of errors, kept separate: **`Panic`** (bugs in us — crash the process) and **`SourcedError`** (boundary failures — handle per-source).

```typescript
import { assert, panic, assertNever, must, unwrap, Panic, SourcedError, isSourcedError } from '@adriangalilea/utils'

// Assert invariants — narrows types via `asserts condition`
assert(port > 0 && port < 65536, 'invalid port:', port)

// Impossible state
switch (state) {
  case 'ready': handleReady(); break
  default: panic('impossible state:', state)
}

// Exhaustiveness check — TS compile error if you miss a case
type Event = { kind: 'click' } | { kind: 'hover' } | { kind: 'scroll' }
function handle(e: Event) {
  switch (e.kind) {
    case 'click': return handleClick()
    case 'hover': return handleHover()
    // forgot 'scroll' → TS error: Argument of type '{ kind: "scroll" }' not assignable to 'never'
    default: return assertNever(e)
  }
}
// Add a new variant to Event → every assertNever site lights up at compile time.

// Unwrap operations that shouldn't fail (sync + async)
const data = must(() => JSON.parse(staticJsonString))
const file = must(() => readFileSync(path))
const resp = await must(() => fetch(url))

// Unwrap nullable values — T | null | undefined → T in one expression
const user = unwrap(db.findUser(id), 'user not found:', id)
const el = unwrap(document.getElementById('app'))
```

#### Typed boundary errors — `SourcedError`

Every external system call should wear its source. When it fails, carry forensics:

```typescript
import { SourcedError, isSourcedError, Panic } from '@adriangalilea/utils'

try {
  return await stripe.charges.create({ customer, amount })
} catch (e) {
  throw new SourcedError({
    source: 'stripe',
    operation: 'charge_customer',
    message: e instanceof Error ? e.message : String(e),
    status: (e as any)?.statusCode,
    cause: e,
    context: { customer, amount },
  })
}

// At catch boundaries — keep Panics and SourcedErrors separate:
try { await doWork() }
catch (e) {
  if (e instanceof Panic) throw e                            // bug in us — crash
  if (isSourcedError(e, 'stripe') && e.status === 402) {
    // TS knows e.source === 'stripe' here (generic narrows)
    return { error: 'card declined' }
  }
  if (isSourcedError(e)) {
    log.scope(e.source).error(e.operation, { err: e })  // source, operation, status, context, cause
    throw e
  }
  throw e                                                     // unknown — re-throw
}
```

Every `SourcedError` carries `source`, `operation`, `status`, `context`, and the original exception via `cause`. Call `.toJSON()` for serialization across process boundaries.

## Features

- **Logger**: one event stream shared byte for byte with go-utils and py-utils (lines and spans, W3C trace ids), json or human rendering, scoped env-controlled levels
- **Currency**:
  - 13,750+ crypto symbols from CoinGecko (auto-updatable)
  - Alternative ticker support (XBT→BTC, wrapped tokens, etc.)
  - Optimal decimal calculations
  - Percentage and basis point utilities
  - Fiat and stablecoin detection
- **Format**: Number and currency formatting with compact notation
- **CLI**: ANSI-aware tables/kv/trees + semantic palette, and live output — pinned self-repainting region, spinner, progress bar, with logs flowing above and clean non-TTY degradation
- **Offensive Programming**: assert, panic, assertNever, must, unwrap (throw `Panic`) + SourcedError for typed boundary failures
- **File Operations**: Read, write with automatic path resolution
- **Directory Operations**: Create, list, walk directories
- **KEV**: Redis-style environment variable management with monorepo support
- **XDG**: XDG Base Directory paths — reads env vars set by [xdg-dirs](https://github.com/adriangalilea/xdg-dirs), falls back to spec defaults
- **Unseen**: Persistent dedup filter — "what's new since last time?" for cron/monitoring workflows
- **Project Discovery**: Find project/monorepo roots, detect JS/TS projects
- **LLM caller (`llm`)**: multi-provider failover on top of the Vercel AI SDK — priority ordering, per-key circuit breaker (KV-backed), reset events for live previews, usage accounting with actual billed cost (OpenRouter), tool calling. Worker-safe.
- **Bot plugins (GramIO)**: `kit` (graceful shutdown + admin context), `access-control` (gate + approve/deny menu, backed by sessions), `llm` (draft-streamed replies `streamChatReply` + per-thread conversation history `ctx.llm`), `payments` (Telegram Stars: VIP tiers + credits + perks + waiver + refund flow, ToS-compliant, Spanish-autónomo-aware), `coalesce`, `language`, `menu`

### XDG Base Directories

XDG paths that respect env vars from [xdg-dirs](https://github.com/adriangalilea/xdg-dirs) with spec-compliant fallbacks:

```typescript
import { xdg, dir } from '@adriangalilea/utils'

xdg.state('notify')                    // ~/.local/state/notify
xdg.state('notify', 'watchers.json')   // ~/.local/state/notify/watchers.json
xdg.config('myapp')                    // ~/.config/myapp
xdg.data('myapp')                      // ~/.local/share/myapp
xdg.cache('myapp')                     // ~/.cache/myapp
xdg.runtime('myapp')                   // $XDG_RUNTIME_DIR/myapp

// Ensure the directory exists before writing
dir.create(xdg.state('notify'))
```

### Unseen

"What's new since last time?" — filters an array of objects to only the ones you haven't seen before. Remembers across runs.

```typescript
import { unseen } from '@adriangalilea/utils'

const messages = await fetchMessages()
const newMessages = await unseen('messages', messages, 'id')
```

1st run:
```
messages    = [{ id: '1', from: 'alice', text: 'hi' }]
newMessages = [{ id: '1', from: 'alice', text: 'hi' }]
```

2nd run, no new message:
```
newMessages = []
```

3rd run, bob replied:
```
messages    = [{ id: '1', ... }, { id: '2', from: 'bob', text: 'hey' }]
newMessages = [{ id: '2', from: 'bob', text: 'hey' }]
```

Saves state to: `$XDG_STATE_HOME/unseen/{name}.json`

### Polyglot strings (`say`)

A typed multi-language string is just an object literal `{ en, es, … }` — the keys are the source of truth, the TS compiler enforces completeness, there's no JSON file / extraction tool / registry.

```typescript
import { say, type Polyglot } from '@adriangalilea/utils/say'

say({ en: 'Hello', es: 'Hola' }, 'es')       // → 'Hola'
say({ en: 'Hello', es: 'Hola' }, 'fr')       // TS error: '"fr"' not in '"en" | "es"'

// parametric — closures, no wrapper:
const greeting = (name: string) => ({ en: `Hi ${name}`, es: `Hola ${name}` })
say(greeting('Adrian'), 'es')                 // → 'Hola Adrian'

// type your own adapter:
const notify = (msg: Polyglot<'en' | 'es'>, lang: 'en' | 'es') =>
  transport.send(say(msg, lang))
```

In a bot, `bot/language` adds `ctx.lang` + `ctx.say`. Both resolve stored explicit pick → Telegram client hint → configured default, at READ time — the hint is never persisted, and `session.language` is written only by an explicit pick (the plugin's menuItem action):

```typescript
ctx.say({ en: 'Continue', es: 'Continuar' })       // → string
await ctx.say.send({ en: 'Hi', es: 'Hola' })       // → ctx.send(resolved)
await ctx.say.edit({ en: 'Done', es: 'Listo' })    // → ctx.editText (callback only)
await ctx.say.answer({ en: 'OK', es: 'OK' })       // → ctx.answer (callback only)
```

### Telegram HTML (`tg-html`)

Transform arbitrary (LLM-emitted) HTML into Telegram-compatible `parse_mode=HTML` with opinionated, consistent spacing. Telegram's HTML subset has no headings, lists, or block layout — send it `<h1>`/`<ul>` and the API rejects the whole message. `transform()` accepts the HTML a model naturally writes and renders the structure typographically: `h1` → bold+underline + blank line, `h2…h6` → bold + blank line, `ul/li` → `• ` bullets, blocks get blank lines, blockquotes keep their internal line breaks, allowed tags pass through with attributes filtered, unknown tags drop their markup (content kept), and stray `<`/`>` escape instead of vanishing. Zero dependencies, no DOM, Worker-safe.

```typescript
import { transform } from '@adriangalilea/utils/tg-html'

transform('<h1>Title</h1><ul><li>Point one</li><li>Point two</li></ul>')
// '<b><u>Title</u></b>\n\n• Point one\n• Point two'
```

### Telegram Markdown (`tg-md`)

The MARKDOWN twin of `tg-html`, for the markdown LLMs actually emit (grown in xtldr's bot, moved here verbatim). One opinionated dialect — `_` is NEVER emphasis so `snake_case` survives, every tag balanced by construction so Telegram can't reject a render, pure whole-string functions so full-frame draft repaints (`bot/draft`) degrade malformed mid-stream markdown to literal text — rendered to every shape a bot sends:

```typescript
import { markdownToRichHtml, markdownToTelegramHtml, toPlainText, tidyRichMarkdown } from '@adriangalilea/utils/tg-md'

markdownToRichHtml(md, coverUrl) // rich-message HTML (Bot API 10.1): real <h1>/<ul>, cover <img> spliced after the title
markdownToTelegramHtml(md)       // strict parse_mode=HTML subset: bold-underlined title, breathing headings, • bullets, ###### footer stays quiet
toPlainText(md)                  // last-ditch fallback; quotes keep their > marker
tidyRichMarkdown(md)             // strip the stray ```fence``` models wrap replies in
```

Zero dependencies, Worker-safe. Typography (heading air, bullet glyph, title underline) IS the opinion; it gets options when a second consumer needs a different look, not before.

### Telegram bot plugins (GramIO)

Plugins for personal Telegram bots built on [GramIO](https://gramio.dev). Each plugin lives at its own subpath; peer deps (`gramio`, `@gramio/storage`, `@gramio/session`, `@gramio/format`) are **all optional** — install only what you import.

```bash
pnpm add @adriangalilea/utils gramio @gramio/storage @gramio/session
```

#### One bot file, ideation → production (`bot/create`)

`createBot` is the composer: it constructs the storage + session pair ONCE and threads it into every feature, so "must be the SAME instance you passed to session()" is unrepresentable instead of a doc warning. The same file runs in every stage — **storage and transport are environment decisions, never code shape**:

| stage | run | session | transport |
|---|---|---|---|
| ideation | `BOT_TOKEN=… tsx bot.ts` | memory (ephemeral, announced) | long-poll |
| experiment | `BOT_PERSIST=./bot.sqlite tsx bot.ts` | sqlite | long-poll |
| prod · your own hardware | systemd/launchd unit running the same command | sqlite / `BOT_PERSIST=redis://…` | long-poll (a legitimate prod mode: dials out, no inbound port/TLS) |
| prod · Cloudflare Worker | `wrangler deploy` (D1 binding `DB`) | D1 (`bot/storage-d1`) | webhook (`bot/worker`) |
| prod · Node/Bun server | webhook behind your HTTP server | sqlite/redis | fetch-shaped handler |

```typescript
import { createBot } from '@adriangalilea/utils/bot/create'

const app = createBot({
  admins: 190202471,
  language: { supported: ['en', 'es'] as const, default: 'en' },
  menu: {
    adminContact: '@you',
    header: async (ctx) => `⚙️ hi ${ctx.from?.firstName}`,   // async — read your db here
    items: [/* … */],
    personalData: { onForget: async (ctx, userId) => {/* wipe YOUR tables */} },
  },
  handlers: (bot) => bot.command('start', (ctx) => ctx.say({ en: 'hi', es: 'hola' })),
})

export default app                       // Worker: webhook + /setup + /pause + deploy DMs
if (app.isMain(import.meta)) app.poll()  // Node: `tsx bot.ts` long-polls
```

Cloudflare is one adapter, not the architecture: `bot/worker` and `bot/storage-d1` are the workerd cap (~each 100 lines, both optional); `bot/kit`'s `gracefulStart` is the Node twin (signals + start/stop DMs) and stays the one deliberately Node-only corner. The worker-safe tripwire (`pnpm test:worker-safe`) guarantees the core never grows a Node dependency.

#### Design rules the bot plugins hold to (each learned the hard way)

- **Resolution is read-time.** Language resolves stored pick → live Telegram hint → default on every surface (`ctx.lang`, `ctx.say`, menu chrome, picker highlight). Inferred values are NEVER persisted — a user who switches their client language moves with it until they pick.
- **Only explicit user signals are stored.** The session holds picks, consent, state the user created — never derived values, never render caches. If a sync signature ever tempts you to cache a rendered string into the session, the signature is the bug (menu resolvers are async for exactly this reason).
- **Derives cover every event the user can speak through** — message, callback, AND inline. A feature that skips inline forces consumers to fork shadow helpers that read the session directly; those forks then read as design.
- **Forget actually forgets.** `personalData.onForget` runs inside the same try as the session delete: your message logs/metrics/credit rows get wiped too, or the user is told it failed — never a partial erasure reading as success.
- **The composer owns instance wiring.** Features still compose manually (below) when you need full control, but every "same instance" contract has one home.

#### Threaded Mode

Telegram's [Threaded Mode](https://telegram.org/blog/threaded-conversations) for private chats (BotFather → Bot Settings → Threaded Mode) auto-threads out of the box: `@gramio/contexts` ≥0.9 (what gramio ≥0.12 resolves) forwards `message_thread_id` on every `ctx.send` / `ctx.sendDocument` / `ctx.reply` — including from callback handlers, which carry `threadId` too. No fork, no override.

**Runs on Cloudflare Workers / bun / anywhere.** Every bot subpath below is import-safe off Node — no filesystem access, no `node:*` modules, no import-time side effects anywhere in its graph — **except `bot/kit`**, whose `gracefulStart` handles process signals. `pnpm test:worker-safe` walks every graph and screams on regression.

| Subpath | What it does |
|---|---|
| `@adriangalilea/utils/bot/ctx` | Structural ctx types (`BotMessageCtx`, `BotCallbackCtx`, …) and the `narrow<T>(ctx)` cast helper. Pure typing — no runtime state. |
| `@adriangalilea/utils/bot/keys` | The bot-id key namespace, a **persisted contract**: `botId(ctx)`, `botStorageKey(ctx, userId)` → `bot-<id>:<userId>`, `botSubKey(ctx, sub)` → `bot-<id>:<sub>`. Every storage key the library writes derives from here, and those keys live in YOUR Redis/D1 rows — the shape never changes. Pure functions of `ctx.bot.info.id` (no env/fs), which is what keeps the whole bot surface Worker-safe. |
| `@adriangalilea/utils/bot/kit` | The Node-only corner, one function: `gracefulStart(bot, opts?)` — SIGINT/SIGTERM → `bot.stop()` → exit; force-kills if shutdown hangs; calls `bot.syncCommands()` before `bot.start()`. DMs the admin `@<bot> started.` / `@<bot> shutting down.` by default when `KEV.TELEGRAM_ADMIN_ID` is set (graceful only — crashes don't trigger `onStop`); pass `notifyAdmin: false` to disable or `notifyAdmin: 12345` for an explicit chat id. |
| `@adriangalilea/utils/bot/admin` | `adminContext(admins)` — decorates `ctx.adminId` (the primary admin: the approve/deny and notification target) and `ctx.isAdmin` (any admin) on every event. Ids arrive POSITIONALLY as a number, an array, or a FUNCTION consulted per update, so a db-backed resolver changes who is admin with no restart. Reads no env and touches no OS, which is what keeps it Worker-safe; a Node bot composes env in at the call site (`adminContext(kev.int('TELEGRAM_ADMIN_ID', 123))`). `parseIdList("123, 456 789")` turns an env string into clean ids. `accessControl` and the payments refund flow declare a gramio dependency on this plugin, so gramio throws at `bot.start()` if you forget it. |
| `@adriangalilea/utils/bot/session` | `botSession(opts)` — **drop-in replacement for `@gramio/session`'s `session()`** that auto-namespaces every key as `bot-<id>:<senderId>` using `ctx.bot.info.id` (populated by `getMe()` at startup). Use this instead of `session()` — full stop. Multiple bots sharing one Redis/D1 stay isolated by construction; every plugin in this package derives the same prefix internally via `botStorageKey(ctx, userId)` / `botSubKey(ctx, sub)` (from `bot/keys`). No regex, no manual prefix argument, no way to forget. Worker-safe. |
| `@adriangalilea/utils/bot/notify` | Best-effort admin DMs, worker-safe (you pass the ids; no env, no process). `notifyAdmins(bot, adminIds, text, extra?)` — DM each admin, failures logged and swallowed (a notification must never take the bot down). `alertAdminError(bot, adminIds, label, error, throttle?)` — truncated `🚨 label\nName: message`, rate-limited through a caller-owned `alertThrottle(ms?)` so a failure storm sends one alert per window. `gracefulStart`'s start/stop DMs are built on this. |
| `@adriangalilea/utils/bot/profile` | `syncBotProfile(bot, { name?, description?, about?, photo?, commands?, expects?, adminIds? })` — the bot's Telegram-facing identity as CODE, never BotFather: localized name / description / About / public command list, reconciled idempotently on every boot (get → compare → set per field per language; unchanged values cost one read, so cold-start firing is rate-limit-free). `expects: { inline: true }` declares the BotFather-only capabilities the code assumes — a mismatch (inline mode off on an inline-dependent bot) DMs the admins, since the API can only detect it, not fix it. Never throws; failures log and the bot keeps running. |
| `@adriangalilea/utils/bot/access-control` | Personal-bot ACL — gates non-admin/non-default users; admin gets DM with `[✅ Aprobar][❌ Denegar]` on first attempt; `/access` opens a persistent menu (revoke / reapprove / list pending). Backed by `@gramio/session` per-user + a small index. **Native alternative**: BotFather → Bot Settings → Access → "Restrict bot usage" — flat allow-list at Telegram. Use this plugin when you want in-bot approval flow instead of a BotFather round-trip; both can coexist. |
| `@adriangalilea/utils/bot/allow-list` | Static allow-list by **id and/or @username** — stateless, no session/storage. `allowList({ ids?, usernames? })` is a plugin that decorates `ctx.allowed` (boolean); gate in your handlers (`if (!ctx.allowed) return`). `makeAllowList(...)` is the pure framework-free predicate. The light counterpart to `access-control` (which adds an approve/deny flow + revocable store, needing session+storage). Username caveat: a `@username` is optional and mutable, and the Bot API can't resolve username→id ahead of time — prefer `ids` when known, `usernames` is the pragmatic fallback. |
| `@adriangalilea/utils/bot/groups` | Group-chat identity as plain functions: `chatIdOf(ctx)` (the chat id on any ctx flavour), `isGroupChat(ctx)` / `isPrivateChat(ctx)`, and `isGroupAdmin(ctx, { chatId?, userId? }?)` — the `getChatMember` creator/administrator check behind every admin-gated group setting (auto-summary toggles, "dismiss" buttons on group welcomes), chat/user defaulted from the ctx. Every reader resolves both gramio spellings — message ctxs carry `chat`, callback ctxs (inline-button taps) only `chatId`/`message.chat` — so the same gate works in handlers and taps; read chat ids via `chatIdOf`, never `ctx.chat?.id`. Takes real gramio ctxs and `MenuCtx` with no cast; a miswired ctx panics, an API rejection fails closed (`false`). Composition over policy: bot-owner override stays at your call site (`ctx.isAdmin \|\| await isGroupAdmin(ctx)`). |
| `@adriangalilea/utils/bot/language` (picker surface) | The language-picker vocabulary and surfaces, so bots don't fork label lists: `flagFor` / `autonym` / `languageLabel` ("🇪🇸 Español"), `languagePickerItem({ label, codes, isActive, pick })` (the 2-up primary-highlighted MenuItem factory — storage and policy live in your closures), and `addLanguageRows(kb, { codes, pack, active?, activeStyle? })` (the raw-InlineKeyboard twin for onboarding / group-welcome keyboards; `activeStyle: "success"` when primary already marks something else on the keyboard). |
| `@adriangalilea/utils/bot/user` | `userLabel(u)` — the conditional "[name] [@username] [id]" line every bot re-rolls for admin DMs and logs: `Ada Lovelace (@ada · 42)` / `Ada (42)` / `@ada (42)` / `id 42`, missing pieces drop instead of padding. Reads both gramio spellings (`firstName` / `first_name`); plain text by design. |
| `@adriangalilea/utils/bot/urls` | Telegram-message URL + entity parsing, on what the platform ALREADY parsed (entities carry exact UTF-16 spans — never `startsWith("/")` or a mention regex). `urlsInMessage(msg)` → the url module's `Url[]`: the visible text scanned by `urlsIn` PLUS `text_link` entities — hyperlinked words whose URL never appears in the text, invisible to any text scanner (what forwarded newsletters carry); a text_link's `raw` is its anchor words and its span covers them, so span-cutting treats linked words as the link. `isCommandMessage(msg)` (a `bot_command` entity at offset 0), `commandToken(msg)` (the leading command without any `@botname` suffix, `/summary@my_bot` → `/summary`; null for ordinary text), `commandAddressee(msg)` (the bot a command is explicitly addressed to, lowercased, null when bare — the group addressing gate: a bare command may belong to any bot in the room, one addressed elsewhere never concerns yours), `cutEntities(text, entities, shouldCut)` (exact span surgery; the predicate sees each visible slice), `messageTextAndEntities(msg)` (text or caption with ITS entity set). Framework-agnostic by shape: gramio's `MessageEntity` class and the raw Bot API object both fit. |
| `@adriangalilea/utils/bot/announce` | The house announcement (news) template for bot mass messages, first-class: an `Announcement` = banner (`# 📰 <bot> News`), ordered sections (canonical `section.info/features/fixes` with 💬/✨/🐛, free-form allowed), italic closer, `· signature` (middle dot by design, never an em dash). `renderAnnouncement(a)` → the Telegram-markdown subset; `renderAnnouncementBodies({en, es, …})` → per-language bodies for a polyglot broadcast engine ("en" is the required fallback anchor). Pure, zero deps. |
| `@adriangalilea/utils/bot/updates` | The pending-update queue of a webhook-less bot token, treated as the destroy-on-read resource it is (`getUpdates` with an offset CONFIRMS — destroys — everything below it). `updateQueue({ token, fetch?, apiRoot? })` → `peek({ limit? })` (webhook state + pending count + the queue head, verbatim; sends NO offset so nothing is ever confirmed — repeatable, zero risk), `count()`, `webhookInfo()`, and `drain({ onBatch, limit?, timeoutS?, quietPolls? })` — the one-chance-safe sweep: each raw batch is handed to `onBatch` (persist it somewhere durable) and AWAITED before the offset advances, because the next call is the confirmation; a crash re-delivers the unconfirmed tail on re-run, and the drain ends only after several consecutive silent long-polls, never on the first empty response. A 409 (webhook registered / competing poller) is a typed `SourcedError`, never a silent retry. Plain `fetch`, injectable for tests; no framework. |
| `@adriangalilea/utils/bot/inline-feedback` | Behavioral tripwire for BotFather's inline feedback probability (gates `chosen_inline_result`; defaults 0%, silently resets on bot transfer and inline-mode toggling, invisible to `getMe`): served-but-never-chosen crosses a threshold → throttled admin DM naming the exact BotFather switch. Storage injected as three ops; never throws into the answer path. |
| `@adriangalilea/utils/bot/coalesce` | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate `message` updates with no marker. Middleware detects the burst and emits one combined event. |
| `@adriangalilea/utils/llm` | Multi-provider LLM caller on the Vercel AI SDK — the policy layer the SDK doesn't ship. `createLlm({ providers, health? })` → `.stream(req)` (typed events: `delta` / `reasoning` / `reset` / `tool-call` / `end`) and `.complete(req)`. **Failover:** priority-ordered across OpenAI-compatible, Anthropic-compatible, and OpenRouter endpoints; multiple keys per provider (health tracked per key fingerprint); per-key circuit breaker with exponential cool-down persisted in any KV-shaped `HealthStore`; an attempt that dies after emitting yields `reset` (discard, next candidate regenerates); empty completions count as failures. **Accounting:** tokens summed across billed attempts; `costUsd` is the ACTUAL charge (OpenRouter usage accounting via `providerMetadata`, or a `cost` field on the raw usage frame) — never a price-table estimate. **Knobs:** per-model temperature, maxTokens caps, `disableThinking` mapped to each dialect. **Tools:** AI SDK `tool()` + `toolChoice` pass through (re-exported). **Tool loop:** tools with an `execute` run when called, and `stopWhen` (the AI SDK's stop conditions, re-exported: `stepCountIs`, `hasToolCall`, or any function of the steps) calls the model again with their results until one holds; the result's `messages` are every turn the call added (the model's and its tools' results), to append to the conversation; usage sums every step. `prepareStep` (the AI SDK's step hook) may replace what the model reads from a step on, which carries forward: a conversation kept inside its window; and it may say how much the model reasons on that step (`reasoning`, mapped to the provider's own option, openai-compatible and OpenRouter), over the request's `reasoning`: a loop that thinks only where its task gets hard. `requestTimeoutMs` times each request to a model, never the tools between them (a tool may wait on a person). Once a tool has run, a failure is the call's own: no other provider is tried, since its tools would act again. The AI SDK runs a turn's calls at once; tools that must run in order share a `SerialJobExecutor` (re-exported). **Trace:** each call is a `chat` span (kind `inference`) under `log` (a logger or span you pass; the root logger otherwise), and each provider tried is an `attempt` span inside it with OpenTelemetry's gen_ai fields (provider, model, `server.address`, input, cached and output tokens), `cost_usd` when the gateway reports one, `ttft_ms` and `decode_ms`; each model call of a tool loop is a `step` span inside the attempt, with its calls, its finish reason, its tokens and its own `ttft_ms`/`decode_ms` (a turn's tools run as their calls stream in, so a step's decode includes them). **Structured output:** `schema` (zod, or `jsonSchema(…, { validate })`) returns the answer parsed and validated as `object`; an answer that fails it fails its provider and the next is tried. A provider with `structuredOutputs: true` enforces the schema while decoding; otherwise the schema is stated in the instructions. **Failures:** an `LlmError` carries `failures`, one per provider tried, typed: `off-schema` (the model answered, not in the schema's shape: its `text`, and `cut` when its token cap stopped it), `empty`, or `failed` (the call itself: its message and HTTP `status`), so a caller tells an answer it can send back to the model from a server that is down. Worker-safe; peers: `ai`, `@ai-sdk/openai-compatible`, `@ai-sdk/anthropic`, `@openrouter/ai-sdk-provider`. |
| `@adriangalilea/utils/bot/llm` | The Telegram side of an LLM chatbot; the model side is `@adriangalilea/utils/llm`. **Output:** `streamChatReply(ctx, events, opts?)` consumes an `AsyncIterable<LlmStreamEvent>` and paints it with Telegram's native message-draft streaming (`sendMessageDraft` full-frame repaints, throttled ~1/s, keepalive under the ~30s draft TTL), then persists the finished markdown via `ctx.send`, entity-split across 4096 by `@gramio/split`. Reasoning models get a thinking phase — streams into the ephemeral draft and evaporates (`reasoning: 'preview'`, default), persists as an expandable blockquote (`'message'`), or never renders at all (`'hidden'`). Upstream `reset` (provider failover) repaints the draft from scratch. Drafts are private-chat-only (+ BotFather forum-topic mode); elsewhere the preview phase is skipped and only the final send happens. Returns `{ content, reasoning, toolCalls, usage, messages }`. The painter underneath is `@adriangalilea/utils/bot/draft` (`createDraftPreview(ctx, { render, throttleMs? })`): dependency-light (no markdown machinery), for producers that push deltas or own their per-frame rendering (plain or rich-message frames) and persist path, with `streamForensics` (render-regression + reset warnings) built in and exported standalone. **History:** `llmHistory({...})` returns `.plugin` (decorates `ctx.llm` with `.add() / .get() / .clear() / .all() / .clearAll()`, per-(user, thread) OpenAI `ChatMessage` shape, persisted in the shared session record so the menu's 🗑 Forget wipes it automatically) AND `.menuItem` (drop-in "🗑 Delete this thread" for `botMenu` — wipes the LLM history AND calls `deleteForumTopic` so the Telegram thread + all its messages disappear from the chat; falls back to history-only clear when no `threadId` is present). |
| `@adriangalilea/utils/metrics` | Declared counters and timings, bounded dimensions, optional actor IDs, and a schema for generic reporting. Inject a store (`store: metricsStore(driver, project)`); measurement failures are reported without rejecting calls. See Product metrics below. |
| `@adriangalilea/utils/metrics/sqlite` | Atomic SQLite driver, explicit provisioning schema, and inclusive UTC daily reports; libSQL is its own module (`metrics/libsql`) over the same driver shape. Connections and credentials belong to the application. |
| `@adriangalilea/utils/bot/menu` | `botMenu({ command, description, items, privacy?, personalData?, adminContact })` — `/settings` command + InlineKeyboard router. Root view always renders a `🛡️ Privacy & data` submenu button that wraps the privacy policy link plus (if `personalData: { storage }`) 🗑 Forget + 📥 Export buttons. Items take `keepRow` (render on the same row as the next item — e.g. a two-per-row language picker) and `rootExtra` (render at the bottom of the root menu, below Privacy & data). `label` / `header` / `style` / `visible` resolvers may be **async** (read your db at render time — never cache render strings in the session); `parseMode: 'HTML'` renders the header formatted (you own escaping); `personalData.onForget(ctx, userId)` wipes YOUR tables inside the same try as the session delete, so Forget either forgets everything or reports failure. `toggleMenuItem({ id, read, write, label: { off, on }, toast? })` — convenience factory for boolean-toggle items with dynamic label + optional toast, storage-agnostic via `read`/`write` closures. |
| `@adriangalilea/utils/bot/payments` | `botPayments({ session, storage, paysupport, paysupportHint?, legal, waiver, vip?, credits?, perks? })` — Telegram Stars monetization in one drop-in plugin. **Three axes, all optional:** `vip` (positional tier ladder — single rung in v1 is just `vip: [{...}]`, ladder is `vip: [{...}, {...}]`; ids are `vip.1`, `vip.2`, …), `credits` (consumable balance + top-up packs `credits.1`, `credits.2`, …), `perks` (orthogonal one-shot unlocks `perks.<key>`). **Surface:** `ctx.payments.atLeast('vip')` / `atLeast('vip.2')` (typed rank check), `ctx.payments.tier()` / `.tier.level()` / `.tier.label()`, `ctx.payments.credits.{balance, consume, tryConsume}` (throws `InsufficientCredits`), `ctx.payments.has(perkId)`, `await ctx.payments.require('vip', { feature? })` (gate that sends a localized upgrade prompt deep-linked to `/settings → 💎 VIP`), `await ctx.payments.invoice(productKey)` (threads Art. 103(m) TRLGDCU consent inline before `sendInvoice`). **Owns:** waiver consent flow (versioned text → forces re-consent on bump, snapshotted on every charge for audit), `/paysupport` slash command (Telegram ToS §6.5; `paysupportHint` overrides the where-to-manage-charges line when your menu isn't `/settings`), idempotent `successful_payment` fulfillment via `pay:idempotency:{chargeId}` sentinel, lazy subscription expiry (no cron needed), tier upgrade auto-cancel of the lower rung's renewal, and admin-DM refund approval (mirror of `accessControl`'s [✅ Aprobar][❌ Denegar] pattern). **Returns:** `{ plugin, menuItem, payouts, admin, onFulfilled, onRefunded }` — `menuItem` is the drop-in `💎 VIP` entry for `botMenu`; `admin.{listCharges, getCharge}` backs custom admin commands; `payouts.{record, list, export, exportForUsers}` is the Fragment payout ledger (you receive TON, log the EUR conversion, export time-windowed CSV/JSON for your gestor); `onFulfilled(productKey \| '*', handler)` / `onRefunded(...)` register fire-and-forget hooks (purchase applied / admin-approved refund — a revenue ledger writes on one, reverses on the other). **Stars-only by design** — Telegram ToS §6.2 forbids third-party payment providers for digital goods. Crypto Pay deferred (MiCA risk); Stripe-outside-Telegram is a future v2 channel. Full compliance memo (Spanish-autónomo seller-of-record analysis, Verifactu vs Crea y Crece, MiCA, Art. 103(m) waiver text, GDPR retention) in `src/bot/payments/CLAUDE.md`. |
| `@adriangalilea/utils/bot/create` | `createBot<S>({ token?, storage?, initial?, admins?, language?, menu?, access?, payments?, handlers?, worker? })` — the composer (see "One bot file, ideation → production" above). Returns `{ build, session, poll, isMain, fetch }`: `poll()` long-polls, `export default app` is a complete Worker, `app.session(ctx)` / the `handlers` callback's `session(ctx)` is the TYPED accessor for your `S` fields. Owns storage+session wiring; resolves storage per environment (D1 binding → `bot/storage-d1`; `BOT_PERSIST` path → sqlite, `redis://` → redis, lazily-imported optional peers; else announced-ephemeral memory) — or pass `storage: (env) => Storage` when the choice is env-dependent (e.g. a D1 binding not named `DB`). Boot NARRATES the composition (`session storage kind="memory (ephemeral …)"`, `features features=["language(en,es)","menu(/settings)"]`) so every implicit decision is visible where you're looking. Runnable demo: `pnpm demo:bot`. |
| `@adriangalilea/utils/bot/worker` | `botWorkerFetch(resolve)` — the Cloudflare Worker cap: secret-checked webhook (ack fast, work + storage flush ride `ctx.waitUntil`, errors DM admins throttled), `POST /setup` (webhook registration with handler-derived `allowed_updates` + a commit-narrating 🚀 deploy DM from the request body), `POST /deploy-started` (the 🛳 "what is shipping" DM — curl it from your deploy script on the still-live version, body `{sha, author, message, etaSeconds?}`), operator-authed `/pause` `/resume` `/webhook-status` (+ `statusExtra`), and a `routes` escape hatch tried before the built-ins. `afterUpdate(ctx.updateId, work)` hands off work the reply does not wait for (a cache warm-up): the update ends without it, so it never holds the conversation's next update or counts toward the durable runner's attempt bound, and the Worker stays alive until it settles (`afterWorkMs` on the runner). Structural bot type — no workers-types dependency. |
| `@adriangalilea/utils/bot/flags` | `defineFlags(spec, { read, write? })` — feature flags declared ONCE in code, resolved LIVE from the bot's operator-config record (D1 json row, Redis hash — storage-agnostic, Worker-safe, zero deps). Each flag: `{ kind: 'bool'\|'number'\|'string', label, help?, default }`, where `default` is a scalar or a **tier map** `{ free, vip?, 'vip.N'? }` resolved off `ctx.payments.tier()` — "free users get this limit, premium users get that model" is one declaration, zero branching at the call site (ladder walks down: exact rung → lower rungs → `vip` → `free`). Read sites: `await flags.<key>(ctx)` — stored override wins over the code default, per-ctx reads coalesce into one config fetch. `flags.describe()` is the JSON schema admin panels render generically, so a new flag appears in every panel with zero panel edits; `flags.set(ctx, key, value)` writes a kind-checked live override (`null` clears it — RFC 7386 null-delete, matching SQLite `json_patch`); `flags.overrides(ctx)` lists what's overridden. Corrupt stored values scream (`SourcedError`); bad specs panic at construction. |
| `@adriangalilea/utils/bot/storage-d1` | `d1Storage({ db, table? })` — `@gramio/storage` adapter over a D1 `session` table (schema in the module doc). `flush()` matters: the session plugin writes un-awaited and workerd freezes the isolate the instant `fetch()` returns — hand `flush` to `bot/worker` (automatic via `createBot`) so writes survive. |

Standard wiring:

```typescript
import { Bot } from 'gramio'
import { redisStorage } from '@gramio/storage-redis'
import { adminContext } from '@adriangalilea/utils/bot/admin'
import { gracefulStart } from '@adriangalilea/utils/bot/kit'
import { botSession } from '@adriangalilea/utils/bot/session'
import { accessControl } from '@adriangalilea/utils/bot/access-control'
import { createLlm } from '@adriangalilea/utils/llm'
import { streamChatReply, llmHistory, toModelMessages } from '@adriangalilea/utils/bot/llm'
import { kev } from '@adriangalilea/utils'

// Raw redis is fine — bot-id namespacing happens inside botSession +
// every plugin via ctx.bot.info.id. Multiple bots sharing this Redis
// stay isolated by construction. No manual prefix to remember.
const storage = redisStorage()
const userSession = botSession({ storage, key: 'session', initial: () => ({}) })
const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 })

// Any OpenAI-compatible endpoint: vllm-mlx, mlx-lm, llama.cpp, Together, Groq, OpenAI, …
// More entries = priority failover; a KV-shaped `health` store adds the circuit breaker.
const llm = createLlm({
  providers: [{ id: 'local', type: 'openai', baseUrl: process.env.LLM_URL!, apiKey: 'none', defaultModel: process.env.LLM_MODEL! }],
})

const bot = new Bot(process.env.BOT_TOKEN!)
  .extend(adminContext(kev.int('TELEGRAM_ADMIN_ID', 190202471)))   // or a live resolver
  .extend(userSession)
  .extend(accessControl({ session: userSession, storage, defaults: [] }))
  .extend(chat.plugin)
  .on('message', async (ctx) => {
    if (!ctx.access.allowed) return
    ctx.llm.add({ role: 'user', content: ctx.text ?? '' })

    // Draft-streamed reply: thinking phase previews + final entity-split send.
    const { content } = await streamChatReply(ctx, llm.stream({
      instructions: 'You are helpful.',
      messages: toModelMessages(ctx.llm.get()),
    }))
    ctx.llm.add({ role: 'assistant', content })
  })

await gracefulStart(bot)
```

Inside handlers, `ctx.access` is a typed discriminated union — `{ allowed: true, source: 'admin' | 'default' | 'store', record? }` or `{ allowed: false, reason }`. `ctx.adminId` and `ctx.isAdmin` are available on every event from `adminContext`.

For tests/demos without a second Telegram account, `simulateAccessRequest(bot, storage, adminId, fakeUser, msg)` injects a synthetic pending request so admin can exercise the approve/deny flow.

### Menu items — coloured buttons, refresh, toast-return, confirm

`MenuItem` supports four cooperating fields for richer UX. Each is opt-in:

```typescript
import { botMenu, toggleMenuItem } from '@adriangalilea/utils/bot/menu'

const menu = botMenu({
  command: 'settings',
  description: 'Open settings',
  adminContact: '@yourhandle',
  personalData: { storage },
  items: [
    lang.menuItem,           // ← submenu, selected lang renders as a blue (primary) button
    chat.menuItem,           // ← red (danger) button with built-in "⚠️ Sure?" confirm step

    // Boolean toggle — dynamic label + automatic colour + auto-refresh + toast.
    toggleMenuItem({
      id: 'thinking',
      read: (ctx) => (ctx.session as { thinking?: boolean }).thinking ?? false,
      write: (ctx, v) => { (ctx.session as { thinking?: boolean }).thinking = v },
      label: {
        off: { en: '💭 Thinking: OFF', es: '💭 Razonamiento: OFF' },
        on:  { en: '💭 Thinking: ON',  es: '💭 Razonamiento: ON'  },
      },
      toast: {
        on:  { en: 'Thinking on.',  es: 'Razonamiento activado.'  },
        off: { en: 'Thinking off.', es: 'Razonamiento desactivado.' },
      },
    }),

    // Custom destructive action with an explicit confirm step. The
    // action only runs after the user taps Confirm in the overlay.
    {
      id: 'reset',
      label: { en: '💥 Reset everything', es: '💥 Resetear todo' },
      style: 'danger',
      confirm: {
        prompt: {
          en: '⚠️ Reset ALL your data?\n\nThis is irreversible.',
          es: '⚠️ ¿Resetear TODOS tus datos?\n\nNo se puede deshacer.',
        },
      },
      action: (ctx) => {
        ctx.session.somethingHeavy = undefined
        // Return the toast string — the menu plugin owns the single
        // answerCallbackQuery for the tap. Calling ctx.answer here
        // would be a double-answer and would break refresh.
        return { en: '✅ Reset.', es: '✅ Reseteado.' }
      },
    },
  ],
})
```

Field summary:

- `style: 'primary' | 'success' | 'danger'` (or `(ctx) => …` for state-dependent colouring) maps to Telegram's native [InlineKeyboardButton.style](https://core.telegram.org/bots/api#inlinekeyboardbutton). Use `style` instead of emoji markers (`●`/`○`) for active-selection signalling — same UX, native rendering.
- `refresh: true` re-renders the menu in place after `action` runs, so dynamic `label` / `style` resolvers reflect mutated state without the user re-opening `/settings`. `toggleMenuItem` enables this by default.
- `action` returns `void | string | Polyglot<string>`; the menu plugin sends a single `answerCallbackQuery` with that text. **Never call `ctx.answer(...)` from inside an action** — Telegram rejects the second answer, the action throws, and `refresh` never runs.
- `confirm: { prompt }` adds a one-step confirmation overlay before the action runs. Cancel returns to root. Use this for destructive actions instead of `ctx.answer({ show_alert: true })` — Telegram's alert UI doesn't compose with refresh / toast.
- `disabled` (a `(ctx) => boolean` predicate on action items) renders the button greyed-out and non-tappable (Bot API 10.3 DisabledButton — no callback ever fires). A disabled button can't explain itself: pair it with header/body copy saying why, and keep a guard in the action for clients predating the field. `radioMenuItem` exposes it per choice as `disabledWhen(ctx, value)`.

**Live state inside resolvers**: `label` / `style` / `header` / `visible` resolvers fire AFTER the action mutated the session, and they may be **async** — read your database (or `ctx.session.<field>`) directly at render time; never cache render strings into the session to satisfy a signature. `ctx.lang` from `bot/language` is a snapshot at event start and goes stale within the same callback; `ctx.say(...)` IS live and safe to use anywhere.

See `src/bot/CLAUDE.md` for storage layout, design decisions, and gotchas.

## Release

Bump version in `package.json`, push to `main`. CI runs the shared workflows in [adriangalilea/ci](https://github.com/adriangalilea/ci):

1. `pnpm run verify` (type-check, lint, tests), build, pack, all without publish rights
2. Publish the packed tarball to npm via [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers) (no tokens: GitHub Actions proves identity directly to npm)
3. Create the tag `vX.Y.Z` and the GitHub release, with a changelog from [git-cliff](https://github.com/orhun/git-cliff)

A version with no GitHub release yet is what triggers this, so a run that fails after publishing is finished by the next push.

## License

MIT
