@lovelaces-io/storyteller 0.2.0 → 0.3.1
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 +262 -0
- package/README.md +194 -27
- package/dist/cli.cjs +221 -0
- package/dist/index.cjs +853 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +454 -41
- package/dist/index.d.ts +454 -41
- package/dist/index.js +840 -73
- package/dist/index.js.map +1 -1
- package/llms.txt +109 -0
- package/package.json +26 -7
- package/snippets/agents-section.md +36 -0
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.1 (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,12 +1,14 @@
|
|
|
1
|
+
<p align="left"><img src="site/public/storyteller-logo.svg" alt="" width="56" height="56" /></p>
|
|
2
|
+
|
|
1
3
|
# Storyteller
|
|
2
4
|
|
|
3
5
|
[](https://www.npmjs.com/package/@lovelaces-io/storyteller)
|
|
4
6
|
[](LICENSE)
|
|
5
7
|
[](package.json)
|
|
6
8
|
|
|
7
|
-
Lightweight TypeScript logging library that treats logs as **stories** —
|
|
9
|
+
Lightweight TypeScript logging library that treats logs as **stories** — beats reported as they happen, emitted as a single structured record.
|
|
8
10
|
|
|
9
|
-
Zero dependencies. TypeScript-first. One record per story.
|
|
11
|
+
Zero dependencies. TypeScript-first. One record per story — and a live stream when you want to watch it happen.
|
|
10
12
|
|
|
11
13
|
## Why Storyteller?
|
|
12
14
|
|
|
@@ -53,34 +55,143 @@ const story = new Storyteller({
|
|
|
53
55
|
origin: { who: "checkout-service", where: { app: "web" } },
|
|
54
56
|
});
|
|
55
57
|
|
|
56
|
-
story.
|
|
57
|
-
story.
|
|
58
|
-
story.
|
|
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
|
|
66
|
+
|
|
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",
|
|
73
|
+
});
|
|
74
|
+
|
|
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 });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
For nested work, open a chapter. Each becomes its own record, linked to the parent:
|
|
147
|
+
|
|
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
|
+
}
|
|
59
154
|
```
|
|
60
155
|
|
|
61
|
-
|
|
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.
|
|
62
165
|
|
|
63
|
-
|
|
166
|
+
</details>
|
|
64
167
|
|
|
65
|
-
|
|
66
|
-
|------|-----------|------------|
|
|
67
|
-
| **Story** (JSON) | Clean serializable record | DB storage, monitoring, audit logs |
|
|
68
|
-
| **Report** (text) | Colorized human-readable output | Console, log files, debugging |
|
|
168
|
+
## Two Axes, Not One
|
|
69
169
|
|
|
70
|
-
|
|
170
|
+
**Story vs report** is *what the output looks like*. **Collected vs live** is *when it comes out*. They combine freely:
|
|
71
171
|
|
|
72
|
-
|
|
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
|
|
73
180
|
|
|
74
181
|
```ts
|
|
75
|
-
story.
|
|
76
|
-
story.
|
|
77
|
-
story.
|
|
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
|
|
78
185
|
```
|
|
79
186
|
|
|
80
|
-
|
|
187
|
+
Levels work on individual beats too: `story.report("Retrying", { level: "warn" })`.
|
|
188
|
+
|
|
189
|
+
`level` accepts `"info"`, `"warn"`, `"oops"`, `"error"`, or the stored labels.
|
|
190
|
+
|
|
191
|
+
## Context on Every Beat
|
|
81
192
|
|
|
82
193
|
```ts
|
|
83
|
-
story.
|
|
194
|
+
story.report("Write failed", {
|
|
84
195
|
who: { id: "user:99" },
|
|
85
196
|
what: { field: "email" },
|
|
86
197
|
where: "primary-db",
|
|
@@ -93,32 +204,88 @@ story.note("Write failed", {
|
|
|
93
204
|
Stories are delivered to **audiences**. Console is included by default.
|
|
94
205
|
|
|
95
206
|
```ts
|
|
96
|
-
import { dbAudience } from "@lovelaces-io/storyteller";
|
|
207
|
+
import { dbAudience, ndjsonAudience } from "@lovelaces-io/storyteller";
|
|
97
208
|
|
|
98
|
-
// Store warn and oops
|
|
209
|
+
// Store warn and oops records in your database
|
|
99
210
|
story.audience.add(
|
|
100
211
|
dbAudience(async (event) => await db.insert("logs", event))
|
|
101
212
|
);
|
|
102
213
|
|
|
214
|
+
// One JSON object per line, for a program to read
|
|
215
|
+
story.audience.add(ndjsonAudience({ stream: process.stderr }));
|
|
216
|
+
|
|
103
217
|
// Target specific audiences
|
|
104
|
-
story.
|
|
218
|
+
story.finish("Critical failure", { level: "oops", error }).to("console", "db");
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
An audience declares which emission kinds it wants. `hears` defaults to `["story"]`, so audiences written before live narration keep working unchanged.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
story.audience.add({
|
|
225
|
+
name: "metrics",
|
|
226
|
+
hears: ["note"],
|
|
227
|
+
hear: (emission) => send(emission),
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
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
|
+
});
|
|
105
247
|
```
|
|
106
248
|
|
|
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.
|
|
250
|
+
|
|
251
|
+
## Configuration
|
|
252
|
+
|
|
253
|
+
Every option can also come from the environment, so you can change behavior without touching code:
|
|
254
|
+
|
|
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 |
|
|
261
|
+
|
|
107
262
|
## Quick Reference
|
|
108
263
|
|
|
109
264
|
| Method | Returns | Description |
|
|
110
265
|
|--------|---------|-------------|
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `reset()` | `this` | Clear
|
|
116
|
-
| `summarize(options?)` | `FormattedReport` | Preview current
|
|
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 |
|
|
117
273
|
| `audience.add(member)` | `this` | Register an audience |
|
|
118
274
|
| `audience.remove(name)` | `this` | Unregister an audience |
|
|
119
275
|
| `audience.has(name)` | `boolean` | Check if an audience is listening |
|
|
120
276
|
| `audience.names()` | `string[]` | List who's listening |
|
|
121
277
|
|
|
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
|
+
|
|
122
289
|
## Shared Instance
|
|
123
290
|
|
|
124
291
|
```ts
|