@lovelaces-io/storyteller 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,262 @@
1
+ # Storyteller — Agent Guide
2
+
3
+ ## Overview
4
+
5
+ Storyteller (`@lovelaces-io/storyteller`) is a lightweight TypeScript logging library with zero production dependencies. You report beats of work as they happen; it keeps them and emits them as one structured record when the work finishes. Records go to pluggable audiences.
6
+
7
+ Version: 0.3.0 (pre-1.0, API may change). Dual output: ESM + CJS.
8
+
9
+ ## Narrate your work
10
+
11
+ If you are an agent doing a multi-step task, this is the pattern. Report each step as you take it, then finish once:
12
+
13
+ ```typescript
14
+ import { Storyteller } from "@lovelaces-io/storyteller";
15
+
16
+ const story = new Storyteller({
17
+ origin: { who: "sync-agent", where: { service: "billing" } },
18
+ narration: "live",
19
+ });
20
+
21
+ story.report("Reading config");
22
+ story.report("Fetching invoices", { what: { source: "stripe", page: 1 } });
23
+ story.report("Rate limited, backing off", { level: "warn" });
24
+ story.report("Retry succeeded", { what: { attempt: 2 } });
25
+
26
+ story.finish("Sync complete");
27
+ ```
28
+
29
+ In `live` narration each `report()` is emitted the moment you call it, so whoever is watching sees the work in progress. The full record still lands at `finish()`. Nothing is lost either way.
30
+
31
+ ## Choosing a narration mode
32
+
33
+ | You want | Use |
34
+ |---|---|
35
+ | One record per operation, for storage or audit | `collected` (the default) |
36
+ | Progress visible while the work runs | `live` |
37
+ | To decide without touching the code | leave it unset, set `STORYTELLER_NARRATION=live` |
38
+
39
+ `live` never removes an emission — it adds the beats and still delivers the story. A consumer that wants only beats says so with `hears: ["note"]`, rather than silencing the record.
40
+
41
+ Switch at runtime with `story.narrate("live")`, or push a single urgent beat out of an otherwise collected story with `story.report("...", { live: true })`.
42
+
43
+ ## Streaming loses nothing
44
+
45
+ Every beat carries `storyId` and `sequence`. Beats from one story share its `storyId`, and `sequence` is gap-free from 0, assigned at the moment you call `report()`.
46
+
47
+ That means a consumer holding the streamed beats can order and group them back into exactly the `notes` array the story record would have contained. **Order by `sequence`, never by arrival time** — audiences are async and a slow one lands late.
48
+
49
+ ## Nested work: chapters
50
+
51
+ Real work nests. An agent spawns subtasks; a batch runs per-item operations. Use `chapter()` so each piece is a complete story in its own right while the whole run stays reconstructable:
52
+
53
+ ```typescript
54
+ story.report("Starting sync");
55
+
56
+ for (const account of accounts) {
57
+ const chapter = story.chapter({ origin: { what: account.id } });
58
+ chapter.report("Fetching invoices");
59
+ chapter.report("Reconciling");
60
+ chapter.finish(`Synced ${account.id}`);
61
+ }
62
+
63
+ story.finish("Sync complete");
64
+ ```
65
+
66
+ Each chapter emits its own record carrying `parentStoryId`. Follow that field to rebuild the tree. A chapter shares the parent's audiences — including any added later — and inherits narration, level and delivery settings; pass options to override.
67
+
68
+ Chapters are **not** folded into the parent's notes. One record per story stays true, and a nested story is still a story.
69
+
70
+ ## Report anything
71
+
72
+ `report()` takes any value, not just a string. Do not pre-flatten your data:
73
+
74
+ ```typescript
75
+ story.report(await response.json());
76
+ story.report(caughtError);
77
+ story.report(new Map([["region", "us-east"]]));
78
+ story.report({ message: "Job queued", jobId: 7 }); // "message" becomes the note text
79
+ ```
80
+
81
+ Whatever you pass is normalized into something storable: errors keep their `cause` chain, dates become ISO strings, class instances get an `@type` tag, circular references become `[Circular → path]`, and secret-looking keys (`password`, `apiKey`, `token`, …) become `[redacted]`.
82
+
83
+ Values dropped for size are replaced with an explicit `{ "@truncated": { kind, omitted } }` marker, so you can tell "this was empty" from "this was too big".
84
+
85
+ The normalizer never throws. A hostile object cannot break the pipeline.
86
+
87
+ ## Output a program can read
88
+
89
+ For machine consumption, use NDJSON — one JSON object per line, nothing else on the channel:
90
+
91
+ ```typescript
92
+ import { ndjsonAudience } from "@lovelaces-io/storyteller";
93
+
94
+ story.audience.remove("console");
95
+ story.audience.add(ndjsonAudience({ stream: process.stderr }));
96
+ ```
97
+
98
+ Or set `STORYTELLER_FORMAT=ndjson` and change no code at all.
99
+
100
+ ## Environment variables
101
+
102
+ | Variable | Values | Effect |
103
+ |---|---|---|
104
+ | `STORYTELLER_NARRATION` | `collected` \| `live` | Whether beats stream |
105
+ | `STORYTELLER_FORMAT` | `text` \| `ndjson` | Which default audience is registered |
106
+ | `STORYTELLER_LEVEL` | `info` \| `warn` \| `oops` | Minimum level delivered |
107
+ | `STORYTELLER_COLOR` | `0` \| `1` | Force colors off or on |
108
+ | `STORYTELLER_DEPRECATION_WARNINGS` | `1` | Warn when deprecated methods are called |
109
+
110
+ Unrecognized values fall back to the default rather than throwing.
111
+
112
+ ## Error handling
113
+
114
+ Pass the caught value to `finish()`. It is normalized automatically:
115
+
116
+ ```typescript
117
+ const story = new Storyteller({ origin: { who: "sync-job" } });
118
+
119
+ story.report("Starting sync");
120
+ try {
121
+ const records = await getRecords();
122
+ story.report("Retrieved records", { what: { count: records.length } });
123
+ await writeRecords(records);
124
+ story.finish("Sync finished");
125
+ } catch (error) {
126
+ story.finish("Sync failed", { level: "oops", error });
127
+ }
128
+ ```
129
+
130
+ ## Two output modes, two narration modes
131
+
132
+ These are different axes and it matters that you keep them straight:
133
+
134
+ | | Collected | Live |
135
+ |---|---|---|
136
+ | **Story** (JSON record) | one record at the end | beats stream as JSON, record still lands |
137
+ | **Report** (formatted text) | one grouped block at the end | one compact line per beat |
138
+
139
+ *Story* vs *report* is **what the output looks like**. *Collected* vs *live* is **when it comes out**.
140
+
141
+ `JSON.stringify(event)` gives you the story record — a complete DB row, no assembly required. `formatStory(event)` gives you the human-readable report.
142
+
143
+ ## API
144
+
145
+ ```typescript
146
+ story.report(input, context?) // a beat; returns `this` for chaining
147
+ story.finish(title, options?) // emit the collected story; returns a `.to()` handle
148
+ story.narrate(mode) // switch narration at runtime
149
+ story.chapter(options?) // a child storyteller, linked by parentStoryId
150
+ story.reset() // drop the notes, start a new story id
151
+ story.summarize(options?) // preview without emitting
152
+ story.currentStoryId // the id beats are being tagged with
153
+ story.audience.add/remove/has/names
154
+ ```
155
+
156
+ `context`: `{ who, what, where, error, level, live, to }`
157
+ `options`: `{ level, error }`
158
+ `level` accepts `"info"`, `"warn"`, `"oops"`, `"error"`, or the stored labels.
159
+
160
+ ### Deprecated — removed at 1.0
161
+
162
+ | Old | New |
163
+ |---|---|
164
+ | `note(text, context?)` | `report(input, context?)` |
165
+ | `tell(title)` | `finish(title)` |
166
+ | `warn(title)` | `finish(title, { level: "warn" })` |
167
+ | `oops(title, error?)` | `finish(title, { level: "oops", error })` |
168
+
169
+ The aliases behave identically. `tell` will not be reintroduced with a new meaning.
170
+
171
+ ## Audiences
172
+
173
+ An audience declares which emission kinds it wants. **`hears` defaults to `["story"]`**, so an audience written before live narration existed keeps hearing only stories:
174
+
175
+ ```typescript
176
+ story.audience.add({
177
+ name: "metrics",
178
+ hears: ["note"], // beats only
179
+ accepts: (emission) => emission.level !== "Information",
180
+ hear: (emission) => send(emission),
181
+ });
182
+ ```
183
+
184
+ Built in: `consoleAudience()` (notes and stories, registered by default), `dbAudience(insert)` (stories only, warn and oops), `ndjsonAudience(options)` (notes and stories).
185
+
186
+ When an audience throws, the failure is reported through `onAudienceError` rather than swallowed, and never propagates into your code. When an audience is too slow, emissions past `maxInFlight` are dropped and counted in `droppedEmissions` on the closing story — so the loss shows up in the record instead of vanishing.
187
+
188
+ ## Architecture
189
+
190
+ ```
191
+ src/
192
+ storyteller.ts — core class, types, event building, delivery
193
+ normalize.ts — turns any value into something storable
194
+ environment.ts — env-var config, level resolution
195
+ formatting.ts — formatStory(), presentation logic
196
+ useStoryteller.ts — singleton pattern
197
+ utils.ts — ANSI codes, getLevelColor, formatOrigin, summarizeContext
198
+ audiences/
199
+ consoleAudience.ts — compact line per beat, grouped block per story
200
+ dbAudience.ts — persists warn/oops stories via insert callback
201
+ ndjsonAudience.ts — one JSON object per line
202
+ report/
203
+ writeStoryReport.ts — multi-story report, grouped by day
204
+ cli.ts — `storyteller init`, CommonJS-only so __dirname resolves
205
+ index.ts — public API barrel export
206
+ snippets/
207
+ agents-section.md — the guidance block consumers paste into their AGENTS.md
208
+ ```
209
+
210
+ `snippets/agents-section.md` is the single source for that block. It is embedded
211
+ verbatim in README.md and written by `storyteller init`, and `npm run check:snippet`
212
+ fails the build if the copies drift or it outgrows its 40-line budget. Edit the
213
+ snippet, never a copy.
214
+
215
+ Types are defined in `storyteller.ts` and `normalize.ts`. Formatting utilities live in `utils.ts` — do not duplicate them elsewhere.
216
+
217
+ ## Code standards
218
+
219
+ This repo follows [Lovelaces](https://lovelaces.io) coding standards:
220
+
221
+ - **Descriptive names** — no abbreviations. `options` not `opts`, `error` not `err`, `timestamp` not `ts`.
222
+ - **No single-letter variables.**
223
+ - **JSDoc on every public export.**
224
+ - **No `as any` casts** — use proper type narrowing.
225
+ - **Comments explain why, not what.**
226
+ - **Zero production dependencies** — a hard constraint.
227
+
228
+ ## Anti-patterns
229
+
230
+ ### Do not order streamed beats by arrival time
231
+
232
+ Audiences are async. Two beats can land out of order. `sequence` is assigned synchronously and is the only correct ordering key.
233
+
234
+ ### Do not pre-stringify your data
235
+
236
+ `report()` normalizes anything you give it. `JSON.stringify`-ing first loses structure and can throw on a circular reference before Storyteller ever sees it.
237
+
238
+ ### Do not store the .to() return value
239
+
240
+ The object returned by `finish()` is a one-shot delivery handle. Delivery happens on the next microtask, so `.to()` must be called immediately and synchronously — not after an `await`.
241
+
242
+ ```typescript
243
+ // Wrong — delivery may have already happened
244
+ const handle = story.finish("Done");
245
+ await someAsyncWork();
246
+ handle.to("db");
247
+
248
+ // Correct
249
+ story.finish("Done").to("db");
250
+ ```
251
+
252
+ ### Do not mix presentation with storage
253
+
254
+ Storage audiences should receive the raw emission. Do not format before storing — format when reading.
255
+
256
+ ### Do not create a Storyteller per step
257
+
258
+ One instance per logical operation. Multiple instances fragment your work across disconnected stories with different `storyId`s. Use `useStoryteller()` for shared access, or pass one instance through the call chain.
259
+
260
+ ### Do not report after finishing
261
+
262
+ `finish()` clears the notes and starts a new story id. Beats reported afterwards belong to the next story.
package/README.md CHANGED
@@ -1,8 +1,44 @@
1
+ <p align="left"><img src="site/public/storyteller-logo.svg" alt="" width="56" height="56" /></p>
2
+
1
3
  # Storyteller
2
4
 
3
- Lightweight TypeScript logging library that treats logs as **stories** — grouped notes emitted as a single structured event.
5
+ [![npm](https://img.shields.io/npm/v/@lovelaces-io/storyteller)](https://www.npmjs.com/package/@lovelaces-io/storyteller)
6
+ [![license](https://img.shields.io/npm/l/@lovelaces-io/storyteller)](LICENSE)
7
+ [![zero deps](https://img.shields.io/badge/dependencies-0-brightgreen)](package.json)
8
+
9
+ Lightweight TypeScript logging library that treats logs as **stories** — beats reported as they happen, emitted as a single structured record.
10
+
11
+ Zero dependencies. TypeScript-first. One record per story — and a live stream when you want to watch it happen.
12
+
13
+ ## Why Storyteller?
14
+
15
+ **Before:** 47 scattered `console.log` lines. Something broke. Good luck figuring out what happened.
16
+
17
+ ```
18
+ [14:30:00] User clicked checkout
19
+ [14:30:00] Validating cart...
20
+ [14:30:01] Cart valid
21
+ [14:30:01] Charging card...
22
+ [14:30:03] ERROR: gateway timeout
23
+ [14:30:03] Retrying...
24
+ [14:30:04] Charge succeeded
25
+ ```
4
26
 
5
- Zero dependencies. ~24 kB packed. TypeScript-first.
27
+ **After:** One story. One record. The whole picture.
28
+
29
+ ```json
30
+ {
31
+ "level": "Warning",
32
+ "title": "Payment retry succeeded",
33
+ "durationMs": 4000,
34
+ "notes": [
35
+ { "timestamp": "14:30:00", "note": "User clicked checkout" },
36
+ { "timestamp": "14:30:01", "note": "Cart validated", "what": { "items": 3 } },
37
+ { "timestamp": "14:30:03", "note": "Card declined", "error": { "message": "gateway timeout" } },
38
+ { "timestamp": "14:30:04", "note": "Retry succeeded" }
39
+ ]
40
+ }
41
+ ```
6
42
 
7
43
  ## Install
8
44
 
@@ -10,46 +46,152 @@ Zero dependencies. ~24 kB packed. TypeScript-first.
10
46
  npm install @lovelaces-io/storyteller
11
47
  ```
12
48
 
13
- ## Usage
49
+ ## Quick Start
14
50
 
15
51
  ```ts
16
52
  import { Storyteller } from "@lovelaces-io/storyteller";
17
53
 
18
54
  const story = new Storyteller({
19
- origin: { where: { app: "checkout", page: "Payment" } },
55
+ origin: { who: "checkout-service", where: { app: "web" } },
20
56
  });
21
57
 
22
- // Collect notes as things happen
23
- story.note("User submitted payment", {
24
- who: { id: "user:413" },
25
- what: { amount: 49.99, currency: "USD" },
26
- });
58
+ story.report("User submitted payment", { what: { amount: 49.99 } });
59
+ story.report("Charging card", { where: "stripe" });
60
+ story.finish("Payment completed");
61
+ ```
62
+
63
+ Beats are collected, sorted chronologically, and emitted as one structured record to your audiences.
64
+
65
+ ## Watch It Happen
27
66
 
28
- story.note("Charging card", {
29
- what: "stripe:charge",
30
- where: { service: "payments" },
67
+ Set narration to `live` and each beat is emitted the moment you report it — the record still lands at the end.
68
+
69
+ ```ts
70
+ const story = new Storyteller({
71
+ origin: { who: "sync-agent" },
72
+ narration: "live",
31
73
  });
32
74
 
33
- // Tell the story when it's done
34
- story.tell("Payment completed");
75
+ story.report("Fetching invoices", { what: { source: "stripe" } });
76
+ story.report("Rate limited, backing off", { level: "warn" });
77
+ story.finish("Sync complete");
78
+ ```
79
+
80
+ ```
81
+ 05:36:50 info sync-agent Fetching invoices {source=stripe}
82
+ 05:36:50 warn sync-agent Rate limited, backing off
83
+ ```
84
+
85
+ Or leave the code alone and set `STORYTELLER_NARRATION=live`.
86
+
87
+ Every beat carries `storyId` and a gap-free `sequence`, so a consumer holding the stream can reassemble exactly the record collected narration would have produced. Nothing is lost either way.
88
+
89
+ ## Feed It Anything
90
+
91
+ `report()` takes any value. No pre-flattening, no defensive stringifying.
92
+
93
+ ```ts
94
+ story.report(await response.json());
95
+ story.report(caughtError); // cause chain preserved
96
+ story.report(new Map([["region", "us-east"]]));
97
+ story.report({ message: "Job queued", jobId: 7 });
98
+ ```
99
+
100
+ Circular references become `[Circular → path]`. Secret-looking keys become `[redacted]`. Oversized values get an explicit `{ "@truncated": … }` marker rather than disappearing. The normalizer never throws — a hostile object cannot break your logging.
101
+
102
+ ## Nested Work
103
+
104
+ Real work nests. `chapter()` gives you a child storyteller whose stories link back to the parent:
105
+
106
+ ```ts
107
+ story.report("Starting sync");
108
+
109
+ for (const account of accounts) {
110
+ const chapter = story.chapter({ origin: { what: account.id } });
111
+ chapter.report("Fetching invoices");
112
+ chapter.finish(`Synced ${account.id}`);
113
+ }
114
+
115
+ story.finish("Sync complete");
116
+ ```
117
+
118
+ Each chapter is a complete record of its own, carrying `parentStoryId`. Follow that field to rebuild the whole run as a tree. Chapters share the parent's audiences and inherit its settings.
119
+
120
+ ## Teach Your Agents
121
+
122
+ Run `npx @lovelaces-io/storyteller init` to set up a project in one command — it installs the package, writes a configured storyteller, and adds the block below to your `AGENTS.md`.
123
+
124
+ Or paste it yourself. Every agent that works in the repo will read it:
125
+
126
+ <details>
127
+ <summary>Agent guidance block</summary>
128
+
129
+ ## Logging with Storyteller
130
+
131
+ This project uses [Storyteller](https://github.com/lovelaces-io/storyteller). Narrate multi-step work as you do it, then finish once.
132
+
133
+ ```ts
134
+ import { useStoryteller } from "@lovelaces-io/storyteller";
135
+
136
+ const story = useStoryteller({ origin: { who: "sync-job" } });
137
+
138
+ story.report("Fetching invoices", { what: { source: "stripe" } });
139
+ story.report("Rate limited, backing off", { level: "warn" });
140
+ story.report(await response.json());
141
+
142
+ story.finish("Sync complete");
143
+ // on failure: story.finish("Sync failed", { level: "oops", error });
35
144
  ```
36
145
 
37
- Notes are bundled into one structured event, delivered to your audiences, and cleared for the next story.
146
+ For nested work, open a chapter. Each becomes its own record, linked to the parent:
38
147
 
39
- ## Three Levels
148
+ ```ts
149
+ for (const account of accounts) {
150
+ const chapter = story.chapter({ origin: { what: account.id } });
151
+ chapter.report("Reconciling");
152
+ chapter.finish(`Synced ${account.id}`);
153
+ }
154
+ ```
155
+
156
+ Things that are easy to get wrong:
157
+
158
+ - **Hand it the object.** `report()` takes any value — errors, API responses, Maps, class instances — and structures it safely, including circular references. Never `JSON.stringify` first.
159
+ - **One storyteller per logical operation**, not one per step. Separate instances fragment the work into disconnected stories.
160
+ - **Order beats by `sequence`, not arrival time.** Audiences are async and a slow one lands late.
161
+ - **`.to()` is synchronous.** Call it immediately after `finish()`, never after an `await`.
162
+ - **Report before finishing.** `finish()` clears the notes; anything reported after belongs to the next story.
163
+
164
+ Set `STORYTELLER_NARRATION=live` to watch beats stream as they happen, or `STORYTELLER_FORMAT=ndjson` for one JSON object per line.
165
+
166
+ </details>
167
+
168
+ ## Two Axes, Not One
169
+
170
+ **Story vs report** is *what the output looks like*. **Collected vs live** is *when it comes out*. They combine freely:
171
+
172
+ | | Collected | Live |
173
+ |---|---|---|
174
+ | **Story** (JSON) | one record at the end | beats stream as JSON, record still lands |
175
+ | **Report** (text) | one grouped block at the end | one compact line per beat |
176
+
177
+ `JSON.stringify(event)` gives you the story record — a complete DB row, no assembly. `formatStory(event)` gives you the report.
178
+
179
+ ## Levels
40
180
 
41
181
  ```ts
42
- story.tell("Payment completed"); // success
43
- story.warn("Payment slow but succeeded"); // something was off
44
- story.oops("Payment failed", new Error()); // something broke
182
+ story.finish("Payment completed"); // all good
183
+ story.finish("Payment slow but succeeded", { level: "warn" }); // heads up
184
+ story.finish("Payment failed", { level: "oops", error }); // something broke
45
185
  ```
46
186
 
47
- ## Context on Every Note
187
+ Levels work on individual beats too: `story.report("Retrying", { level: "warn" })`.
48
188
 
49
- Every note can carry `who`, `what`, `where`, and `error`:
189
+ `level` accepts `"info"`, `"warn"`, `"oops"`, `"error"`, or the stored labels.
190
+
191
+ ## Context on Every Beat
50
192
 
51
193
  ```ts
52
- story.note("Write failed", {
194
+ story.report("Write failed", {
53
195
  who: { id: "user:99" },
54
196
  what: { field: "email" },
55
197
  where: "primary-db",
@@ -57,83 +199,107 @@ story.note("Write failed", {
57
199
  });
58
200
  ```
59
201
 
60
- ## Audiences
202
+ ## Audiences — Who Hears Your Stories
61
203
 
62
- Stories are delivered to **audiences**. Console is included by default. Add your own:
204
+ Stories are delivered to **audiences**. Console is included by default.
63
205
 
64
206
  ```ts
65
- import { dbAudience } from "@lovelaces-io/storyteller";
207
+ import { dbAudience, ndjsonAudience } from "@lovelaces-io/storyteller";
66
208
 
67
- // Persist warn and oops events to your database
209
+ // Store warn and oops records in your database
68
210
  story.audience.add(
69
211
  dbAudience(async (event) => await db.insert("logs", event))
70
212
  );
71
213
 
72
- // Target specific audiences per story
73
- story.oops("Critical failure", error).to("console", "db");
74
- ```
214
+ // One JSON object per line, for a program to read
215
+ story.audience.add(ndjsonAudience({ stream: process.stderr }));
75
216
 
76
- ## Summaries
217
+ // Target specific audiences
218
+ story.finish("Critical failure", { level: "oops", error }).to("console", "db");
219
+ ```
77
220
 
78
- Generate a formatted summary without emitting:
221
+ An audience declares which emission kinds it wants. `hears` defaults to `["story"]`, so audiences written before live narration keep working unchanged.
79
222
 
80
223
  ```ts
81
- const summary = story.summarize({
82
- title: "Dashboard status",
83
- level: "tell",
84
- verbosity: "full",
224
+ story.audience.add({
225
+ name: "metrics",
226
+ hears: ["note"],
227
+ hear: (emission) => send(emission),
85
228
  });
86
-
87
- console.log(summary.text);
88
229
  ```
89
230
 
90
- ```
91
- Story: Dashboard status
92
- Level: tell
93
- Time: Mar 22, 2026, 3:42:18 PM (12ms)
94
- Origin: checkout / Payment
95
- Notes:
96
- 3:42:18 PM User submitted payment
97
- 3:42:18 PM — Charging card
231
+ Audiences are small. Only the failures, straight to a Discord channel — fifteen lines, no dependency:
232
+
233
+ ```ts
234
+ story.audience.add({
235
+ name: "discord",
236
+ accepts: (event) => event.level === "Error",
237
+ hear: async (event) => {
238
+ await fetch(process.env.DISCORD_WEBHOOK_URL!, {
239
+ method: "POST",
240
+ headers: { "content-type": "application/json" },
241
+ body: JSON.stringify({
242
+ content: "```\n" + event.summarize({ colors: false, detail: "brief" }).text + "\n```",
243
+ }),
244
+ });
245
+ },
246
+ });
98
247
  ```
99
248
 
100
- ## Shared Instance
249
+ When an audience throws, the failure is reported rather than swallowed, and never reaches your code. When one is too slow, emissions past `maxInFlight` are dropped and counted in `droppedEmissions` on the closing record — visible loss beats silent loss.
101
250
 
102
- Use `useStoryteller()` for cross-component logging into the same story:
251
+ ## Configuration
103
252
 
104
- ```ts
105
- import { useStoryteller } from "@lovelaces-io/storyteller";
253
+ Every option can also come from the environment, so you can change behavior without touching code:
106
254
 
107
- // Same instance everywhere
108
- const story = useStoryteller({ origin: { where: { app: "admin" } } });
109
- ```
255
+ | Variable | Values | Effect |
256
+ |---|---|---|
257
+ | `STORYTELLER_NARRATION` | `collected` \| `live` | Whether beats stream |
258
+ | `STORYTELLER_FORMAT` | `text` \| `ndjson` | Which default audience is registered |
259
+ | `STORYTELLER_LEVEL` | `info` \| `warn` \| `oops` | Minimum level delivered |
260
+ | `STORYTELLER_COLOR` | `0` \| `1` | Force colors off or on |
110
261
 
111
- ## Structured Output
262
+ ## Quick Reference
112
263
 
113
- Every story is a typed, serializable JSON object — designed for humans and machines:
264
+ | Method | Returns | Description |
265
+ |--------|---------|-------------|
266
+ | `report(input, context?)` | `this` | Report a beat — any value, optional who/what/where/error/level |
267
+ | `finish(title, options?)` | `{ to }` | Emit the collected story |
268
+ | `narrate(mode)` | `this` | Switch narration at runtime |
269
+ | `chapter(options?)` | `Storyteller` | A child storyteller, linked by `parentStoryId` |
270
+ | `reset()` | `this` | Clear beats without emitting |
271
+ | `summarize(options?)` | `FormattedReport` | Preview current beats as a formatted report |
272
+ | `currentStoryId` | `string` | The id beats are being tagged with |
273
+ | `audience.add(member)` | `this` | Register an audience |
274
+ | `audience.remove(name)` | `this` | Unregister an audience |
275
+ | `audience.has(name)` | `boolean` | Check if an audience is listening |
276
+ | `audience.names()` | `string[]` | List who's listening |
114
277
 
115
- ```json
116
- {
117
- "timestamp": "2026-03-22T14:15:03.421Z",
118
- "level": "oops",
119
- "title": "Payment failed",
120
- "origin": { "where": { "app": "checkout", "page": "Payment" } },
121
- "notes": [
122
- {
123
- "timestamp": "2026-03-22T14:15:02.218Z",
124
- "note": "User submitted payment",
125
- "who": { "id": "user:413" }
126
- }
127
- ],
128
- "error": { "name": "Error", "message": "gateway timeout" }
129
- }
278
+ ### Deprecated — removed at 1.0
279
+
280
+ | Old | New |
281
+ |---|---|
282
+ | `note(text, context?)` | `report(input, context?)` |
283
+ | `tell(title)` | `finish(title)` |
284
+ | `warn(title)` | `finish(title, { level: "warn" })` |
285
+ | `oops(title, error?)` | `finish(title, { level: "oops", error })` |
286
+
287
+ The aliases behave identically and stay silent unless you set `STORYTELLER_DEPRECATION_WARNINGS=1`. `tell` will not be reintroduced with a new meaning.
288
+
289
+ ## Shared Instance
290
+
291
+ ```ts
292
+ import { useStoryteller } from "@lovelaces-io/storyteller";
293
+
294
+ const story = useStoryteller({ origin: { who: "worker" } });
130
295
  ```
131
296
 
132
297
  ## Docs
133
298
 
134
299
  - [API Reference](docs/API.md) — full signatures and examples
135
- - [How It Works](docs/HOW-IT-WORKS.md) — narrative guide with real-world scenarios
300
+ - [How It Works](docs/HOW-IT-WORKS.md) — narrative guide
136
301
  - [Changelog](CHANGELOG.md)
302
+ - [For AI Agents](AGENTS.md) — guidance for AI coding assistants
137
303
 
138
304
  ## License
139
305