# 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 doctrine the three share, and the harness that proves they emit the same record line, 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.

## Installation

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

## Usage

### Logger

Two renderings, one automatic decision: a TTY gets *human* (symbols, color), anything else gets *record* (level words, plain text, greppable). The same program prints `✓ synced` while you develop it and `2026-08-07T12:34:56Z INFO  synced` the moment launchd points it at a file. Detection happens on the stream actually written to (stderr in Node; stdout stays clean for data).

```typescript
import { wait, step, error, fail, warn, warnOnce, ready, info, success, debug, trace, scope, time, timeEnd } from '@adriangalilea/utils/log'

// Verbs express outcome; each renders at its level (fail is error-level, the rest info)
wait('Loading...')          // ○
step('resolving config')    //   • (indented sub-step)
info('Information message')
success('Operation successful')  // ✓
ready('Server is ready')    // ▶
warn('This is a warning')   // ⚠
error('Something went wrong')    // ⨯
fail('Deploy failed')       // ⨯
debug('Cache miss')         // ◦
trace('Trace message')      // »

// Warn once (won't repeat same message)
warnOnce('This warning appears only once')

// Timer functionality (reports at trace level)
time('operation')
// ... do something
timeEnd('operation') // operation: 123ms

// Scoped logger — lines carry [api], and API_LOG_LEVEL controls it from env
const api = scope('api')
api.info('Request received')          //  [api] Request received
api.scope('auth').warn('token old')   // ⚠ [api auth] token old
```

Levels filter (`silent < error < warn < info < debug < trace`); an unknown `LOG_LEVEL` throws. A scoped logger reads `{SCOPE}_LOG_LEVEL` before `LOG_LEVEL`, live: `GZIP_LOG_LEVEL=silent` shuts one subsystem up without touching code, `GZIP_LOG_LEVEL=trace` opens it wide while the rest stays quiet.

Two knobs, both env + runtime setter, both with sane defaults:

- `LOG_FORMAT=human|record` / `setLogFormat()` — how lines render. Default: TTY → human, otherwise record. Workers and browsers use level-mapped console methods; Node funnels everything to stderr.
- `LOG_TIME=1|0` / `setLogTime()` — whether lines open with time. Default: on in Node record mode (a daemon's log file must answer "when"), off everywhere else (human eyes don't need stamps; Worker and browser consoles stamp lines themselves). Record time is UTC RFC3339; human time is a dim local `HH:MM:SS`.

The record line format is identical across go-utils and py-utils by design:

```
2026-08-07T12:34:56Z WARN  [scope] message
```

### 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`).

### 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 grapheme-aware**: a ZWJ emoji / flag counts as one visible unit in `width()`/`clip()`/`table()` alignment. East-Asian double-width (CJK) is a known TODO — those columns can drift a cell.
- 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, e.toJSON())  // structured forensics
    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**: human/record dual rendering (TTY-detected), scoped env-controlled levels, shared record format with go-utils and py-utils
- **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). 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: memory …`, `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. 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 handles everything:

1. Type-check, lint, build
2. Publish to npm via [OIDC trusted publishing](https://docs.npmjs.com/generating-provenance-statements) (no tokens — GitHub Actions proves identity directly to npm)
3. Create git tag `vX.Y.Z`
4. Generate changelog via [git-cliff](https://github.com/orhun/git-cliff) and create GitHub release

## License

MIT
