@acquiredintelligence/attend 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,138 +1,41 @@
1
- # attend
1
+ <p align="center">
2
+ <img src="docs/assets/banner.jpg" alt="An abstract head drawn in green contour lines, eyes closed, with a comic-style hit mark at its top left where a stream of green dots converges" width="100%">
3
+ </p>
2
4
 
3
- [![ci](https://github.com/acquiredintelligence/attend/actions/workflows/ci.yml/badge.svg)](https://github.com/acquiredintelligence/attend/actions/workflows/ci.yml)
5
+ <h1 align="center">attend</h1>
4
6
 
5
- Decide when an event should wake your agent.
7
+ <p align="center">Decide when an event should wake your agent.</p>
6
8
 
7
- An app emits a firehose of events: log lines, messages, clicks, alerts. An agent that wakes on every one is noisy, and one behind a debounce is deaf to the message that mattered. attend sits between the two. It settles each scope's events into a window, asks a judge what the agent should do about the window, and hands the decision to your app, which keeps the final say in how that route runs.
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/@acquiredintelligence/attend"><img alt="npm version" src="https://img.shields.io/npm/v/@acquiredintelligence/attend"></a>
11
+ <a href="https://github.com/acquiredintelligence/attend/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/acquiredintelligence/attend/actions/workflows/ci.yml/badge.svg"></a>
12
+ <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/npm/l/@acquiredintelligence/attend"></a>
13
+ <img alt="Node 22.13 or later" src="https://img.shields.io/node/v/@acquiredintelligence/attend">
14
+ </p>
8
15
 
9
- ```text
10
- app events (the firehose)
11
- | push() in-process, or NDJSON over HTTP to attend serve
12
- v
13
- +----------------------------------------------------------+
14
- | attend |
15
- | window one per scope; events pile up |
16
- | settle the act in it is finished, or the window |
17
- | is old, or full, or you flushed it |
18
- | judge reads the settled window, picks a route: |
19
- | interrupt | queue | note | ask | skip | wait |
20
- | log every step and decision is persisted |
21
- +----------------------------------------------------------+
22
- | ~4 to 8 decisions a minute, not thousands of events
23
- v
24
- decisions out
25
- |-- in-process: your sink's handler for the route
26
- '-- attend serve: MCP Events poll, stream and tools, for any MCP client
27
- v
28
- your agent reacts (barge in, take it next turn, add context, offer the user a prompt);
29
- your app decides how each route is carried out
30
- ```
16
+ <p align="center">
17
+ <a href="#see-it-decide">Demo</a> ·
18
+ <a href="docs/how-it-works.md">How it works</a> ·
19
+ <a href="#read-the-docs">Docs</a> ·
20
+ <a href="skills/attend/SKILL.md">Agent skill</a>
21
+ </p>
31
22
 
32
- The judge picks the route and the app executes it: `interrupt` means "the agent should hear this now", and your handler decides whether that cancels the turn, steers it or shows a banner. Raw events never leave attend. Only settled, judged decisions do.
23
+ Picture an agent that watches your app. The app never stops talking: log lines, messages, clicks, alerts. Wake the agent on every event and it never finishes a thought. Put it behind a debounce and it sleeps through the one message that mattered.
33
24
 
34
- ```sh
35
- npm install @acquiredintelligence/attend
36
- ```
37
-
38
- attend needs Node 22.13 or later and ships ESM only. The core has no runtime dependencies and imports nothing from Node.
39
-
40
- ## Two ways to run it
41
-
42
- One engine, run either way.
43
-
44
- | | Embedded | `attend serve` |
45
- |---|---|---|
46
- | What | `createAttend` in your process | The same engine as a process of its own: a sidecar next to your app |
47
- | Events in | `attend.push(scope, events)` | `POST /v1/ingest` with NDJSON, from any language |
48
- | Decisions out | Your `sink`, called in-process | MCP Events: `events/poll`, `events/stream`, and the `attend_wait` and `attend_window` tools |
49
- | Use it for | **Real-time agents.** The default. Flush to delivery stays in-process, with no network hop. | Apps that are not TypeScript, several agents on one firehose, or a team that wants one shared attention service |
50
-
51
- Raw events never cross MCP, so the MCP hop carries a few decisions a minute and adds about a millisecond to a judge call that takes 140 ms or more.
52
-
53
- ### attend serve
54
-
55
- ```sh
56
- npm install @acquiredintelligence/attend @modelcontextprotocol/server zod
57
- npx attend serve --store sqlite:attend.db --judge jev --kinds kinds.json
58
- # attend serving http://127.0.0.1:8787 (ingest POST http://127.0.0.1:8787/v1/ingest, MCP http://127.0.0.1:8787/mcp)
59
- ```
60
-
61
- One command, no image. The MCP server package is an optional peer of `@acquiredintelligence/attend`, so the embedded engine stays dependency-free; `attend serve` exits with an error naming it when it is missing. Every flag has an environment variable, and a flag wins.
62
-
63
- | Flag | Env | Default | |
64
- |---|---|---|---|
65
- | `--store` | `ATTEND_STORE` | `memory` | `memory`, `sqlite:<file>`, `postgres:<url>` or `redis:<url>`. Postgres and Redis need the `pg` or `redis` package installed. |
66
- | `--judge` | `ATTEND_JUDGE` | `rule` | `rule` interrupts on a flush and queues otherwise, with no network. `jev` reads `TYPESAFE_API_KEY`. `decisions` reads `OPENAI_API_KEY` and needs `--judge-model`. |
67
- | `--kinds` | `ATTEND_KINDS` | none | Event name to kind, as inline JSON or a file: `{"user.said":"flush","build.failed":"closing"}` |
68
- | `--default-kind` | `ATTEND_DEFAULT_KIND` | `consequence` | Kind for names `--kinds` does not list |
69
- | `--host`, `--port`, `--socket` | `ATTEND_HOST`, `ATTEND_PORT`, `ATTEND_SOCKET` | `127.0.0.1`, `8787` | Port 0 picks a free one. A socket path replaces host and port. |
70
- | `--act-threshold` | `ATTEND_ACT_THRESHOLD` | off | See [the act threshold](#the-act-threshold) |
71
- | `--token` | `ATTEND_TOKEN` | none | Require `Authorization: Bearer <token>` on ingest and MCP |
72
-
73
- Send events from anything that can POST:
74
-
75
- ```sh
76
- curl -H 'content-type: application/x-ndjson' --data-binary @events.ndjson http://127.0.0.1:8787/v1/ingest
77
- # {"scope":"ops/db","id":"a1","at":1760570000123,"name":"alert.fired","data":{},"text":"replica lag 40s on db-2"}
78
- # {"scope":"ops/db","end":true}
79
- ```
80
-
81
- A line is an event, `{"scope", "flush": true}` or `{"scope", "end": true}`. The reply is `{accepted, duplicates, rejected: [{line, scope, error}]}` once every accepted line has committed, and a `429` with `Retry-After` when attend is over its limit. `POST /v1/ingest/stream` keeps one NDJSON request open and answers with acks and credits.
82
-
83
- An agent hears the decisions by subscribing to `attention.decision` on `/mcp`. Hosts that do not subscribe call the `attend_wait` tool in a loop. In TypeScript, `decisionFeed` from `@acquiredintelligence/attend/mcp` keeps the cursor for you. This is [`examples/serve.ts`](examples/serve.ts), which runs the server and a subscriber in one process:
84
-
85
- ```ts
86
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
87
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
88
- import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
89
- import { createAttend, memoryStore } from '@acquiredintelligence/attend';
90
- import { decisionFeed } from '@acquiredintelligence/attend/mcp';
91
- import { serve } from '@acquiredintelligence/attend/server';
92
-
93
- const store = memoryStore();
94
- const attend = createAttend({
95
- store,
96
- kind: (event) => (event.name === 'page.acked' ? 'flush' : event.name === 'alert.fired' ? 'closing' : 'consequence'),
97
- judge: async ({ window }) => ({ route: window.reason === 'flush' ? 'interrupt' : 'note' }),
98
- sink: 'log',
99
- deadLetter: 'log',
100
- });
101
-
102
- const server = serve(attend, store, { listen: { port: 0 } });
103
- await server.ready;
104
- const url = server.url();
105
- console.log(`attend serving ${url}`);
106
-
107
- const client = new Client({ name: 'pager-agent', version: '1.0.0' });
108
- // The SDK's class types sessionId as optional, which exactOptionalPropertyTypes rejects.
109
- await client.connect(new StreamableHTTPClientTransport(new URL(`${url}/mcp`)) as Transport);
110
- const stop = new AbortController();
111
- const feed = decisionFeed(client, { cursor: null, signal: stop.signal })[Symbol.asyncIterator]();
112
- ```
113
-
114
- `serve(attend, store, options)` is the same thing as a library call: it runs `attend.run`, the events server over `store.log` and the ingest routes on one HTTP listener. Give it `principal` to authorize by scope prefix, and close it with `await server.close()`.
115
-
116
- ## Quickstart
25
+ attend holds each scope's events until they add up to something, asks a judge what the agent should do, and hands your app one decision. Your app keeps the final say in how that decision runs. In the sample below, a failed build waits, and the user's question wakes the agent with both.
117
26
 
118
27
  ```ts
119
- import { createAttend, defaultNotice, memoryStore, routes, type Decision } from '@acquiredintelligence/attend';
120
-
121
- const tell = (decision: Decision) => console.log(`[${decision.route}]\n${defaultNotice(decision)}`);
28
+ import { createAttend, defaultNotice } from '@acquiredintelligence/attend';
122
29
 
123
30
  const attend = createAttend({
124
- store: memoryStore(),
125
- kind: (event) => (event.name === 'user.message' ? 'flush' : 'consequence'),
31
+ kind: { 'user.message': 'flush', 'build.failed': 'closing' },
126
32
  judge: async ({ window }) => ({ route: window.reason === 'flush' ? 'interrupt' : 'note' }),
127
- sink: routes({ interrupt: tell, queue: tell, note: tell, ask: tell }),
128
- deadLetter: (letter) => console.error('dead letter', letter.id, letter.message),
33
+ sink: (decision) => console.log(`[${decision.route}]\n${defaultNotice(decision)}`),
129
34
  });
130
35
 
131
- void attend.run({ signal: AbortSignal.timeout(1000) });
132
- await attend.push('session-1', [
133
- { id: 'e1', at: Date.now(), name: 'build.failed', data: {}, text: 'Build 4521 failed: 1 test' },
134
- { id: 'e2', at: Date.now(), name: 'user.message', data: {}, text: 'can you look?' },
135
- ]);
36
+ void attend.run();
37
+ await attend.push('session-1', { name: 'build.failed', text: 'Build 4521 failed: 1 test' });
38
+ await attend.push('session-1', { name: 'user.message', text: 'can you look?' });
136
39
  ```
137
40
 
138
41
  The build failure alone never wakes the agent. The user's message settles the window at once, and the agent hears both lines in one notice:
@@ -144,11 +47,17 @@ can you look?
144
47
  Attend to this now, even mid-task.
145
48
  ```
146
49
 
147
- Before production, replace the function judge with [`jevJudge` or `llmJudge`](#judges) and `memoryStore` with a [database store](#storage).
50
+ ## Install
51
+
52
+ ```sh
53
+ npm install @acquiredintelligence/attend
54
+ ```
148
55
 
149
- ## Demo
56
+ attend needs Node 22.13 or later and ships ESM only. The engine has no runtime dependencies. Before production, replace the function judge with [a real judge](docs/judges.md) and pass `store` [a database store](docs/storage.md); without one, state lives in memory.
150
57
 
151
- `pnpm demo` starts a real MCP Events server in the same process, streams a short CI story into attend, and prints what the agent hears. It needs no API key and no database. The source is [`examples/demo.ts`](examples/demo.ts); the script builds the package and runs it with `node --experimental-strip-types`, so Node 22.13 or later runs it as TypeScript.
58
+ ## See it decide
59
+
60
+ In a clone of this repo, `pnpm demo` streams a short CI story from a real MCP Events server into attend and prints what the agent hears. It needs no API key and no database. The source is [`examples/demo.ts`](examples/demo.ts).
152
61
 
153
62
  ```text
154
63
  attend demo: a CI server streams MCP Events, and attend decides what the agent hears.
@@ -181,396 +90,81 @@ attend demo: a CI server streams MCP Events, and attend decides what the agent h
181
90
  7 events in, 2 deliveries out. Waking on every event would have run the agent 7 times.
182
91
  ```
183
92
 
184
- attend held the log lines until the failed test closed the window. The judge chose `wait` for the failure, so the user's comment merged into the held window, and one interrupt carried all six events to the agent. The deploy arrived as a `note`.
93
+ attend held the log lines until the failed test closed the window. The judge chose `wait`, so the user's comment joined the held window, and one interrupt carried all six events to the agent. The deploy arrived as a `note`.
94
+
95
+ ## What you get
96
+
97
+ - **6 routes.** The judge picks `interrupt`, `queue`, `note`, `ask`, `wait` or `skip`, and your app carries it out.
98
+ - **Windows that settle when an act ends.** A finished build or a message to the agent settles the window. A log line never wakes the agent on its own.
99
+ - **Your choice of judge.** TypeSafe's Jev, the OpenAI Decisions API, any model through `llmJudge`, or a plain function.
100
+ - **4 stores.** Memory, SQLite, Postgres and Redis, each crash-tested at every commit boundary in CI.
101
+ - **At-least-once delivery.** A decision keeps its id across retries, so your sink can drop repeats.
102
+ - **0 runtime dependencies** when embedded, or one command to serve decisions over MCP Events.
185
103
 
186
104
  ## How it works
187
105
 
188
106
  ```text
189
- MCP Events, webhooks, push()
190
- |
107
+ app events (the firehose)
108
+ | push() in-process, or NDJSON over HTTP to attend serve
191
109
  v
192
- kind(event) closing | flush | continuous | passive | consequence | context
193
- |
110
+ +----------------------------------------------------------+
111
+ | attend |
112
+ | window one per scope; events pile up |
113
+ | settle the act in it is finished, or the window |
114
+ | is old, or full, or you flushed it |
115
+ | judge reads the settled window, picks a route: |
116
+ | interrupt | queue | note | ask | skip | wait |
117
+ | log every step and decision is persisted |
118
+ +----------------------------------------------------------+
119
+ | a handful of decisions
194
120
  v
195
- window one per scope, persisted with the source's cursor
196
- | settles when its earliest deadline passes
121
+ decisions out
122
+ |-- in-process: your sink's handler for the route
123
+ '-- attend serve: MCP Events poll, stream and tools, for any MCP client
197
124
  v
198
- judge(window) interrupt | queue | note | ask | skip | wait
199
- | |
200
- v +-- held, judged again later
201
- sink.deliver(decision) at least once, idempotent on the decision id
125
+ your agent reacts (barge in, take it next turn, add context, offer the user a prompt);
126
+ your app decides how each route is carried out
202
127
  ```
203
128
 
204
- 1. A source hands events to a scope. A scope is whatever one agent attends to: a conversation, a repo or a device. Each scope has one persisted state: a cursor per attached source, the open window, the judge slot and the outbox.
205
- 2. `kind(event)` gives each event a settle role when it arrives. attend stores the kind on the event, so a later change to `kind` never reinterprets an open window.
206
- 3. The window settles at the earliest deadline among its events. A window that holds only `consequence` and `context` events never settles.
207
- 4. The judge sees the settled window and picks a route. One judge call runs per scope at a time. Events that arrive during the call wait in the next window.
208
- 5. The decision is appended to the store's decision log in the same write as the state change, so a restart loses nothing. In-process, the sink receives it; over `attend serve`, subscribers read it from the log. It carries the route, the window, a probability for each route and the answers to any predicates. The judge never writes text. `defaultNotice(decision)` builds the agent's notice from the events.
209
-
210
- ### Kinds
211
-
212
- | Kind | Use it for | Settles the window |
213
- |---|---|---|
214
- | `closing` | The end of an act: a submit, a save, a merged pull request, a finished build | `closingGraceMs` after the last closing event, so the output it causes can join the window |
215
- | `flush` | Someone addresses the agent directly: a message to it, an @mention | At once. `wait` is not offered. |
216
- | `continuous` | One step of an ongoing act: typing, scrolling, dragging, a partial transcript | Once no continuous event has arrived for `streamGapMs` |
217
- | `passive` | A dwell or a look: a hover, a focus, a page view | `passiveQuietMs` after the last passive event |
218
- | `consequence` | System output: a log line, a status change, a tool result | Never. It joins the open window. |
219
- | `context` | The agent's own output | Never. It joins the open window as context for the judge, and `defaultNotice` leaves it out. |
129
+ The judge picks the route, and your app carries it out. `interrupt` means the agent should hear this now, and your handler decides whether that cancels the turn, steers it or shows a banner. Raw events never leave attend. Only settled, judged decisions do. [How attend works](docs/how-it-works.md) walks through each step, and [the reference](docs/reference.md) lists every kind, route and default.
220
130
 
221
- `closing`, `flush`, `continuous` and `passive` events are acts: things that happen in the scope and can call for the agent, whether the user did them or an outside party did. A window also settles when its first act is `maxWindowMs` old (reason `age`), when it fills its share of the judge's context window (reason `size`, see [the window budget](#the-window-budget)), when you call `attend.end(scope)` because the producer knows the unit is over (reason `closing`, `wait` still offered), or when you call `attend.flush(scope)` (reason `forced`, with `wait` not offered). When a window passes `maxEvents`, attend drops its oldest consequence and context events and counts them in `window.dropped`. If the window is still too big, it settles with reason `overflow`. attend never drops an act. Consequence and context events that wait longer than `maxWindowMs` for an act are dropped and counted the same way.
131
+ ## Run it in your process or beside it
222
132
 
223
- A system event that should wake the agent on its own, such as a failed deploy or a fired alert, needs an act kind. `closing` is the usual choice.
224
-
225
- ### Routes
226
-
227
- | Route | The agent | Reaches the sink |
133
+ | | Embedded | `attend serve` |
228
134
  |---|---|---|
229
- | `interrupt` | Hears it now, even mid-turn. | Yes |
230
- | `queue` | Hears it when its current turn ends. | Yes |
231
- | `note` | Gets it as context for its next turn and does not respond to it. | Yes |
232
- | `ask` | Stays quiet. Your app offers the user a prompt to call the agent in. | Yes |
233
- | `skip` | Hears nothing. | No. The verdict joins the scope's recent verdicts. |
234
- | `wait` | Hears nothing yet. attend holds the window and judges it again with the next window that settles, or after `waitHorizonMs` together with everything that arrived meanwhile, with `wait` withheld. | No. A window waits at most `maxWaits` times. |
235
-
236
- attend picks the route, and your sink carries it out. `routes()` makes you write a handler for each of the four delivered routes.
237
-
238
- ## MCP Events
239
-
240
- `mcpEventsSource` from `@acquiredintelligence/attend/mcp` turns one MCP Events subscription into a source. Install the peer dependencies with `npm install @modelcontextprotocol/sdk zod`; zod 3.25 or later and zod 4 both work. This excerpt is from [`examples/mcp-events.ts`](examples/mcp-events.ts), with `attend` set up as in the quickstart and `controller` an `AbortController`:
241
-
242
- ```ts
243
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
244
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
245
- import { mcpEventsSource } from '@acquiredintelligence/attend/mcp';
246
-
247
- const client = new Client({ name: 'my-agent', version: '1.0.0' });
248
- await client.connect(new StdioClientTransport({ command: 'node', args: ['./ci-events-server.js'] }));
249
-
250
- const source = mcpEventsSource(client, {
251
- name: 'repo.activity',
252
- arguments: { repo: 'acme/shop' },
253
- onError: (error) => console.warn('mcp events', error),
254
- });
255
-
256
- await Promise.all([
257
- attend.run({ signal: controller.signal }),
258
- attend.attach('repo:acme/shop', source, { signal: controller.signal }),
259
- ]);
260
- ```
261
-
262
- The source covers these parts of the draft:
263
-
264
- - In the default `auto` mode it reads `events/list`, streams with `events/stream` when the event lists `push` delivery, and polls with `events/poll` otherwise. A `-32026` (Unsupported) reply to a stream falls back to polling.
265
- - Every batch carries the server's cursor, and attend commits it in the same write as the events, under the source's key (`mcp:<name>:<arguments>`). After a restart, `attach` resumes from the committed cursor.
266
- - `eventId` becomes `Event.id`, so the scope drops redelivered events.
267
- - `truncated: true` sets `window.gap`. The judge sees it, and `defaultNotice` says that events were lost.
268
- - When a poll result sets `hasMore`, the source polls again at once. Otherwise it waits `nextPollMs`, or `pollMs` (default 10000) when the server sends none.
269
- - A terminated stream reopens from the committed cursor. So does a stream with more than `maxBuffered` (default 1000) notifications waiting on a slow store, which is reported as `overflow`, and a stream that sends nothing, heartbeats included, for `idleTimeoutMs` (default 300000), which is reported as `idle`.
270
- - A `-32025` (ResourceExhausted) reply or a request timeout is thrown as a plain error, so `attach` backs off through `retryBackoffMs` and resubscribes from the committed cursor. Any other error ends the subscription: `attach` rejects with a `PermanentError` whose `cause` is the SDK error.
271
- - When the Client's transport closes, `attach` rejects with a `PermanentError` that says the Client closed. Reconnect the Client and attach again.
272
- - `onError` receives each `notifications/events/error`, each payload that fails validation, each `overflow`, each `idle`, and each `unrouted` notification, one that names a subscription no stream here opened. None of them ends the subscription.
273
- - Several streams can share one `Client`. attend installs one handler for each of the five `notifications/events/*` methods on the Client, records the JSON-RPC id of each `events/stream` request and routes notifications by `_meta["io.modelcontextprotocol/subscriptionId"]`. Do not replace those handlers.
274
-
275
- MCP events carry no one-line summary, so `Event.text` stays unset and `defaultNotice` prints each event's name and JSON data.
276
-
277
- ## Judges
278
-
279
- **The speed default is a system-one classifier.** `jevJudge` (TypeSafe's Jev, with your own TypeSafe key) and `decisionsJudge` (the OpenAI Decisions API) answer a fixed multiple-choice question with probabilities in roughly 140 ms or more, which is what a real-time agent can afford on every settled window. `llmJudge` calls a general model you already use; it is slower, and its self-reported probabilities are close to one-hot. Use it when you want reasoning over speed.
280
-
281
- A judge is a function, `(input: JudgeInput, signal: AbortSignal) => Promise<Judgment>`. It gets the settled `window`, the `routes` on offer with your criteria, your `guidance`, the `predicates` to answer, live `facts` from `context()` and the scope's `recent` verdicts. It returns `{ route, probabilities?, predicates? }`.
282
-
283
- - Throw to retry with backoff. Throw a `PermanentError` to dead-letter the window at once.
284
- - A route that was not offered counts as a retryable failure. Check `input.routes` before you answer `wait`.
285
- - Stop when `signal` aborts. attend aborts the call after `judgeTimeoutMs`.
286
-
287
- ### llmJudge
288
-
289
- `llmJudge` from `@acquiredintelligence/attend/llm` works with any model that answers in JSON matching a schema. You pass that one call as `complete`. attend writes the prompt and the schema and validates the reply. With Claude:
290
-
291
- ```ts
292
- import Anthropic from '@anthropic-ai/sdk';
293
- import { PermanentError } from '@acquiredintelligence/attend';
294
- import { llmJudge } from '@acquiredintelligence/attend/llm';
295
-
296
- const anthropic = new Anthropic();
297
-
298
- export const judge = llmJudge({
299
- complete: async ({ system, prompt, images, schema, signal }) => {
300
- const res = await anthropic.messages.create(
301
- {
302
- model: 'claude-opus-5-5',
303
- max_tokens: 1024,
304
- system,
305
- messages: [
306
- {
307
- role: 'user',
308
- content: [
309
- { type: 'text', text: prompt },
310
- ...images.map((image) => ({ type: 'image' as const, source: { type: 'base64' as const, media_type: image.mimeType, data: image.data } })),
311
- ],
312
- },
313
- ],
314
- output_config: { effort: 'low', format: { type: 'json_schema', schema } },
315
- },
316
- { signal },
317
- );
318
- if (res.stop_reason === 'refusal') throw new PermanentError('judge refused');
319
- const text = res.content.find((b) => b.type === 'text');
320
- return JSON.parse(text?.type === 'text' ? text.text : 'null');
321
- },
322
- });
323
- ```
324
-
325
- [`skills/attend/references/judges.md`](skills/attend/references/judges.md) has the same judge on the OpenAI Responses API and the Vercel AI SDK. The default prompt is `DEFAULT_SYSTEM`, and each route's default criterion is in `DEFAULT_CRITERIA`. Override them with `system` and `criteria`.
326
-
327
- ### jevJudge
328
-
329
- `jevJudge` from `@acquiredintelligence/attend/jev` calls TypeSafe's Jev, a classifier built for this kind of question, with your own TypeSafe key. It asks one `choice` question for the route and one `noul` question per predicate, and sends the state as JSON. The model defaults to `jev-latest`; `jev-preview`, `jev-1.14.0` and `jev-1.13.0` are the others. Pass `endpoint` to go through OpenRouter or LiteLLM, and `longContext: true` for 262k tokens of context at some cost in speed and accuracy. Its probabilities are real, so [the act threshold](#the-act-threshold) works with it.
330
-
331
- ```ts
332
- import { jevJudge } from '@acquiredintelligence/attend/jev';
333
-
334
- export const judge = jevJudge({ apiKey: process.env.TYPESAFE_API_KEY ?? '' });
335
- ```
336
-
337
- ### decisionsJudge
338
-
339
- `decisionsJudge` from `@acquiredintelligence/attend/decisions` calls the OpenAI Decisions API. It asks one `choice` question for the route and one `predicate` question per predicate.
340
-
341
- ```ts
342
- import { decisionsJudge } from '@acquiredintelligence/attend/decisions';
343
-
344
- export const judge = decisionsJudge({ apiKey: process.env.OPENAI_API_KEY ?? '', model: '<model id>' });
345
- ```
346
-
347
- Both hosted judges retry network errors and 408, 429 and 5xx responses, and dead-letter the window at once on any other 4xx, on a billing or quota 429, and when the model refuses the route question. Every error from a response carries `status`, `requestId` and `retryAfterMs`. Both return the classifier's `confidence` in the route on `decision.confidence`.
348
-
349
- ### The window budget
350
-
351
- A window must never overload the judge that reads it. A judge declares its input budget (`jevJudge` declares 32k tokens, or 262k with `longContext`; `llmJudge` and `decisionsJudge` take `contextTokens`), and attend turns it into a size limit on the open window: `(contextTokens - reservedTokens) * windowShare * bytesPerToken`, with `windowShare` defaulting to 0.5. attend keeps a running size estimate for each window as events arrive. A window that reaches its share settles early with reason `size`, and a judge that still gets more than fits drops sheddable events (consequence and context, oldest first) when it renders and never an act or a flush. As a backstop, a provider's context-length rejection triggers exactly one retry with the window halved. A judge that declares no budget leaves the limit off. Set `settle.maxWindowBytes` yourself to override.
352
-
353
- ### The act threshold
354
-
355
- By default the judge's route stands. A judge that returns real probabilities (`jevJudge`, `decisionsJudge`) also ranks how much the agent should act, and only your app knows whether an interruption or a missed moment costs more. Set `actThreshold` in (0, 1] on `createAttend`, and the route becomes an acting route (`interrupt` or `ask`, whichever is likelier and offered) exactly when `p(interrupt) + p(ask)` is at least the threshold, and a quieter route (`skip`, `note`, `queue` or `wait`, by probability) otherwise. It is off by default. A judgment without probabilities is never changed, and a changed route emits a `routeThresholded` trace. The threshold is applied before the decision is logged, so replays stay deterministic.
356
-
357
- Pick it from your own traffic instead of guessing. Replay a recorded stream with the threshold off, record each judgment, and `suggestThreshold` from `@acquiredintelligence/attend/testing` returns the smallest threshold whose acts fit an interruptions-per-hour budget, with the rate at every candidate:
358
-
359
- ```ts
360
- import { suggestThreshold } from '@acquiredintelligence/attend/testing';
361
- import type { Judge, Judgment } from '@acquiredintelligence/attend';
362
-
363
- export const judgments: Judgment[] = [];
364
- export const recording = (judge: Judge): Judge => async (input, signal) => {
365
- const judgment = await judge(input, signal);
366
- judgments.push(judgment);
367
- return judgment;
368
- };
369
-
370
- // After replaying an hour of recorded events through createAttend({ judge: recording(judge), ... }):
371
- export const { threshold, candidates } = suggestThreshold(judgments, { durationMs: 3_600_000, maxActsPerHour: 6 });
372
- ```
373
-
374
- `threshold` is null when no threshold fits the budget. `llmJudge`'s probabilities are near one-hot, so a threshold changes little there.
375
-
376
- ### Images
377
-
378
- Give `createAttend` a `capture(scope, signal)` that returns a `JudgeImage` (base64 `data` and a `mimeType`) or a list of them, such as a screenshot of what the user is looking at, and every judge call carries them as `input.images`. A capture that throws or takes longer than `captureTimeoutMs` (default 500) never fails the call: the judge runs without images and an observer sees a `captureFailed` trace. `jevJudge` sends PNG and JPEG within the provider's limits (280 to 1568 px per side, 16 MiB per file) and drops the rest, reporting each through `onImageError`; `decisionsJudge` sends up to 128 of any format. Either one retries once without images when the provider rejects them. `llmJudge` hands them to `complete` as `images`, as the snippet above shows.
379
-
380
- ### Your own judge
381
-
382
- Any function with the judge signature works, and judges compose as functions. This one answers a direct message without calling a model:
383
-
384
- ```ts
385
- import type { Judge } from '@acquiredintelligence/attend';
386
-
387
- export const withShortcut =
388
- (judge: Judge): Judge =>
389
- async (input, signal) =>
390
- input.window.reason === 'flush' ? { route: 'interrupt' } : judge(input, signal);
391
- ```
392
-
393
- To ask the judge yes-or-no questions beside the route, pass `predicates` to `createAttend`, such as `{ asked: 'Did the user ask the agent for this?' }`. Each answer arrives on `decision.predicates` as a probability.
394
-
395
- ## Sinks
396
-
397
- A sink delivers decisions to the agent. attend calls `sink.deliver(decision, signal)` at least once per decision, one decision at a time per scope, oldest first. After a crash it can deliver the same decision again with the same id, so make the sink idempotent on `decision.id`, which is unique across scopes. Throw to retry, or throw a `PermanentError` to dead-letter at once.
398
-
399
- `routes()` builds a sink from one handler per delivered route. A sink never sees `skip` or `wait`. This excerpt from [`examples/postgres-worker.ts`](examples/postgres-worker.ts) writes every decision to an inbox table that the agent's runtime reads:
400
-
401
- ```ts
402
- async function enqueue(decision: Decision): Promise<void> {
403
- await pool.query('INSERT INTO agent_inbox (scope, decision_id, route, notice) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [
404
- decision.scope,
405
- decision.id,
406
- decision.route,
407
- defaultNotice(decision),
408
- ]);
409
- }
410
-
411
- const attend = createAttend({
412
- store: postgresStore(pool),
413
- kind: (event) => (event.name === 'message.created' ? 'flush' : event.name === 'job.completed' ? 'closing' : 'consequence'),
414
- judge: async ({ window }) => ({ route: window.reason === 'flush' ? 'interrupt' : 'queue' }),
415
- sink: routes({ interrupt: enqueue, queue: enqueue, note: enqueue, ask: enqueue }),
416
- deadLetter: async (letter) => {
417
- await pool.query('INSERT INTO agent_dead_letters (scope, id, letter) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', [letter.scope, letter.id, JSON.stringify(letter)]);
418
- },
419
- });
420
- ```
421
-
422
- `deadLetter` is required. It receives a window that no judge or sink could take, with the stage that gave up, the error message and the attempt count. attend retries a dead letter with capped backoff until the handler succeeds, so write it to durable storage and keep the handler simple. Dead letters travel in their own lane, so a failing handler never holds back decisions; once the lane holds `maxDeadLetters` of them, `push` and a source's `emit` reject with `OutboxFullError` until it drains, and nothing is dropped.
423
-
424
- ## Storage
425
-
426
- One `ScopeStore` holds each scope's latest snapshot, the inputs committed since it, the due index and the decision log. attend replays the short tail of inputs over the snapshot to recover a scope, so a restart loses nothing.
427
-
428
- | Store | Create it | Guarantees | Caveats |
429
- |---|---|---|---|
430
- | Memory | `memoryStore()` from `@acquiredintelligence/attend` | Atomic within one process. | State is lost when the process exits. Use it for tests, demos and browser apps. |
431
- | SQLite | `sqliteStore(db)` from `@acquiredintelligence/attend/sqlite`, with `db` a `node:sqlite` `DatabaseSync`, a `bun:sqlite` `Database`, or any object with the same `exec` and `prepare`. `sqliteStore({ file, worker: true })` runs the database on a worker thread. | One transaction per group of writes. Durable on a file. Creates its tables on construction. | Given a handle, calls block the event loop for their duration; the worker mode does not. Processes that share the file must run on one host. |
432
- | Postgres | `postgresStore(pool)` from `@acquiredintelligence/attend/postgres`, after `await migrate(pool)` | One statement commits a group of scopes' writes together, and decisions wake subscribers in other processes through `LISTEN` and `NOTIFY` when you pass a `listener`. Durable. Safe for many replicas. | Run `migrate()` at deploy with a role that can create tables. The store needs only `SELECT`, `INSERT`, `UPDATE` and `DELETE`. The table prefix must be lowercase. PostgreSQL 13 or later. |
433
- | Redis | `redisStore((args) => client.sendCommand(args))` from `@acquiredintelligence/attend/redis` | One script validates and writes a group of scopes' writes together, and keeps the decision log in a stream. Redis 6.2 or later. | Durable only as far as your Redis persistence settings. On Redis Cluster, a group's keys must hash to one slot. Put a hash tag in the prefix, such as `redisStore(send, { prefix: 'attend:{app}:' })`. |
434
-
435
- Each store also takes `retain` (decision rows kept by count or age), so the log does not grow without bound. Every store passes the same conformance and crash suites.
436
-
437
- Several processes can share one store. Any of them can `push`, and any number can `run` or serve the log: a write names the stored version it read, a writer that loses reloads and applies its input again, and every input is idempotent. Give every process the same `kind` function, because the process that ingests an event is the one that kinds it. A scope can take several sources at once; the record keeps one cursor per `Source.key`. Attach a given source to a scope from one process only, since two processes running the same source would overwrite each other's cursor, and a second `attend.attach` of the same key in one process throws.
438
-
439
- Each adapter also has an `EventLog`: `memoryLog`, `sqliteLog`, `postgresLog` and `redisLog`. When producers live in other processes and you want a durable shared log in front of attend, append to the log and attach `logSource(log, scope)` instead of calling `push`.
440
-
441
- ## Guarantees
442
-
443
- - **One snapshot and a short tail per scope.** The snapshot holds the open window, the judge slot and the outbox. A write appends the inputs that changed state, and a snapshot replaces the tail once it grows past the snapshot's own size. A source's cursor commits in the same write as the events it covers.
444
- - **Versioned writes.** Each write names the version it read. A writer that loses waits a few jittered milliseconds, reloads and applies its input again. After 8 losses in a row it gives up: `push` and `flush` reject with `ConflictError`, and work in flight recovers when its lease expires.
445
- - **Effects only after a committed write.** attend calls the judge and the sink only for state it has persisted. `push` and `flush` persist and return; the runner settles, judges and delivers. If a process crashes after a write and before its effect, the work stays in the store, and a runner retries it when its lease expires.
446
- - **The decision log is the delivery for subscribers.** The decision row commits with the state change that made it. Subscribers read it by cursor, so a reconnect resumes where it stopped; a cursor that fell behind retention is reported as `truncated`, never silently skipped.
447
- - **At-least-once delivery with stable ids.** A decision's id is a hash of the scope and its window's event ids, so a redelivery carries the same id and no two scopes share one. Deliveries in one scope run one at a time, oldest first.
448
- - **Dead letters are never dropped.** The judge and the sink each get `retryBackoffMs.length` retries. A window that still fails becomes a dead letter, which attend retries with capped backoff until `deadLetter` accepts it. A lane that fills to its cap (`maxOutbox` decisions, `maxDeadLetters` dead letters) makes `push` and a source's `emit` reject until it drains.
449
- - **Crash-tested at every commit boundary.** `crashSuite` runs a scripted session once, then once more for each commit with a crash just before it and again just after it. Every run must deliver the same decision ids and dead-letter ids. CI runs it against the memory, SQLite, Postgres and Redis stores.
450
-
451
- ## Testing with a fake clock
452
-
453
- `@acquiredintelligence/attend/testing` makes settling deterministic. Inject `fakeClock()`, answer with `scriptedJudge`, and assert on `recordingSink`. This excerpt is from [`examples/testing.ts`](examples/testing.ts), which runs with `node --test`:
454
-
455
- ```ts
456
- import assert from 'node:assert/strict';
457
- import { test } from 'node:test';
458
- import { createAttend, memoryStore, type DeadLetter, type Event, type Kind } from '@acquiredintelligence/attend';
459
- import { fakeClock, recordingSink, scriptedJudge, type ScriptedAnswer } from '@acquiredintelligence/attend/testing';
460
-
461
- const kind = (event: Event): Kind => {
462
- if (event.name === 'user.message') return 'flush';
463
- if (event.name === 'build.finished') return 'closing';
464
- return 'consequence';
465
- };
466
-
467
- function setup(script: ScriptedAnswer[]) {
468
- const clock = fakeClock();
469
- const judge = scriptedJudge(script);
470
- const sink = recordingSink();
471
- const letters: DeadLetter[] = [];
472
- const attend = createAttend({
473
- store: memoryStore(),
474
- kind,
475
- judge,
476
- sink,
477
- clock,
478
- deadLetter: (letter) => {
479
- letters.push(letter);
480
- },
481
- });
482
- const controller = new AbortController();
483
- const running = attend.run({ signal: controller.signal });
484
- const stop = async () => {
485
- controller.abort();
486
- await running;
487
- };
488
- return { clock, judge, sink, letters, attend, stop };
489
- }
490
-
491
- test('a finished build is judged after the closing grace, with its log lines', async () => {
492
- const { clock, judge, sink, letters, attend, stop } = setup([{ route: 'queue' }]);
493
- await attend.push('repo', [
494
- { id: 'log-1', at: 0, name: 'build.log', data: { line: 'pnpm test' } },
495
- { id: 'done-1', at: 0, name: 'build.finished', data: { ok: false } },
496
- ]);
497
- await clock.advance(499);
498
- assert.equal(judge.calls.length, 0);
499
- await clock.advance(1);
500
- assert.deepEqual(judge.calls[0]?.window.events.map((e) => e.id), ['log-1', 'done-1']);
501
- assert.deepEqual(sink.delivered.map((d) => d.route), ['queue']);
502
- assert.deepEqual(letters, []);
503
- await stop();
504
- });
505
- ```
506
-
507
- - `clock.advance(ms)` fires every timer due on the way and lets each one's work finish before it returns.
508
- - `scriptedJudge` answers in order, repeats its last answer, and records every input in `calls`. An answer can also be `{ fail: 'message' }`, `{ fail: 'message', permanent: true }` or `{ hang: true }`.
509
- - `recordingSink` records `delivered` and `attempts`. `failNext(n)` makes the next `n` deliveries throw.
510
- - Pass the same clock to `mcpEventsSource` and `logSource` through their `clock` option, so their polling runs on fake time too.
511
- - `harness` runs attend on a fake clock over a store that it can crash at any commit. Use it for restart tests.
512
-
513
- ## Writing an adapter
514
-
515
- A store implements `ScopeStore` (see [`src/store/types.ts`](src/store/types.ts), where each member's guarantee is in its doc comment):
516
-
517
- - `load(scope)` returns the latest snapshot and the inputs committed after it, or `null`.
518
- - `commit(writes)` applies each scope's write only if its stored version equals `expectedVersion` (0 means no stored scope), and returns `'ok'` or `'conflict'` for each. The inputs, the snapshot, the due value and the decision rows of one write land together or not at all. Of N concurrent writes at one version, exactly one is `'ok'`.
519
- - `due(now, limit, after?)` pages the scopes whose due value has passed. It reads and claims nothing.
520
- - `remove(scope)` forgets the scope.
521
- - `log` is the `DecisionLog`: the epoch, bounds, reads by position, `find`, `wait` and `trim`.
522
-
523
- Run the conformance suites and the crash suites against your adapter with any test runner. Each returns named async cases. [`examples/adapter-suites.ts`](examples/adapter-suites.ts) runs them against `memoryStore` with `node:test`:
135
+ | What | `createAttend` in your process | The same engine as a process of its own: a sidecar next to your app |
136
+ | Events in | `attend.push(scope, events)` | `POST /v1/ingest` with NDJSON, from any language |
137
+ | Decisions out | Your `sink`, called in-process | MCP Events: `events/poll`, `events/stream`, and the `attend_wait` and `attend_window` tools |
138
+ | Use it for | **Real-time agents.** The default. Flush to delivery stays in-process, with no network hop. | Apps that are not TypeScript, several agents on one firehose, or a team that wants one shared attention service |
524
139
 
525
- ```ts
526
- import { test } from 'node:test';
527
- import { memoryStore } from '@acquiredintelligence/attend';
528
- import { crashSuite, multiScopeCrashSuite, scopeStoreConformance, decisionLogConformance } from '@acquiredintelligence/attend/testing';
529
-
530
- for (const c of scopeStoreConformance(() => memoryStore())) test(c.name, c.run);
531
- for (const c of crashSuite(() => memoryStore())) test(c.name, c.run);
532
- for (const c of decisionLogConformance(() => memoryStore())) test(c.name, c.run);
533
- for (const c of multiScopeCrashSuite(() => memoryStore())) test(c.name, c.run);
534
- ```
140
+ To run the sidecar, see [Run attend as a sidecar with attend serve](docs/serve.md).
535
141
 
536
- The factory must return a fresh, empty store for each case. For a database, give each case its own table prefix or key prefix. An `EventLog` adapter runs `logConformance(make, { canBlock })`, where `canBlock` says whether `read` waits for `blockMs`.
142
+ ## Read the docs
537
143
 
538
- ## Defaults
144
+ | Page | Read it to |
145
+ |---|---|
146
+ | [How attend works](docs/how-it-works.md) | understand scopes, windows, settling and the decision log |
147
+ | [Reference](docs/reference.md) | look up kinds, routes, guarantees and defaults |
148
+ | [Choose a judge](docs/judges.md) | pick and configure Jev, the Decisions API, `llmJudge` or your own, and set the window budget and the act threshold |
149
+ | [Deliver decisions to your agent](docs/sinks.md) | write an idempotent sink and a dead-letter handler |
150
+ | [Choose a store](docs/storage.md) | run on memory, SQLite, Postgres or Redis, and share a store between processes |
151
+ | [Give a Mastra agent attention](docs/mastra.md) | wire attend in front of a Mastra agent with one sink |
152
+ | [Feed attend from an MCP Events server](docs/mcp-events.md) | subscribe a scope to an MCP Events stream with `mcpEventsSource` |
153
+ | [Run attend as a sidecar](docs/serve.md) | send events over HTTP and subscribe agents over MCP |
154
+ | [attend serve reference](docs/serve-reference.md) | look up every flag and the ingest wire format |
155
+ | [Test with a fake clock](docs/testing.md) | make settling deterministic in your tests |
156
+ | [Write a store adapter](docs/adapters.md) | implement `ScopeStore` and run the conformance and crash suites |
539
157
 
540
- These are round starting points, not tuned values. Override any of them with `settle` in `createAttend`. Times are in milliseconds.
158
+ ## Let your coding agent wire it in
541
159
 
542
- | Setting | Default | What it does |
543
- |---|---|---|
544
- | `closingGraceMs` | 500 | How long after a closing event the window settles |
545
- | `streamGapMs` | 1000 | How long every stream must be quiet before a continuous gesture settles |
546
- | `passiveQuietMs` | 4000 | How long after a passive event the window settles |
547
- | `waitHorizonMs` | 10000 | How long a waiting window waits for the next window before attend judges it alone |
548
- | `maxWaits` | 2 | How many times one window may wait |
549
- | `maxEvents` | 250 | Events one window holds. Past it, attend drops consequence and context events, then settles. |
550
- | `maxWindowMs` | 60000 | Age of the first act at which the window settles |
551
- | `windowShare` (a `createAttend` option) | 0.5 | Share of the judge's context budget a window may fill before it settles with reason `size` |
552
- | `actThreshold` (a `createAttend` option) | off | Act when p(interrupt) + p(ask) reaches it |
553
- | `retryBackoffMs` | `[1000, 4000, 16000]` | Backoff before each retry of a judge call, a delivery or a source. Its length is the retry budget for judges and sinks; a source repeats the last rung. |
554
- | `judgeTimeoutMs` | 15000 | Aborts a judge call. The judge's lease is twice this. |
555
- | `deliveryTimeoutMs` | 10000 | Lease on one delivery. An expired lease counts as a failed attempt. |
556
- | `recentCount` | 8 | Recent verdicts the judge sees |
557
- | `seenCount` | 500 | Event ids each scope remembers for dedup |
558
- | `maxOutbox` | 1000 | Undelivered decisions per scope before `push` and a source's `emit` reject with `OutboxFullError` |
559
- | `maxDeadLetters` | 1000 | Undelivered dead letters per scope before `push` and a source's `emit` reject with `OutboxFullError` |
560
- | `deadLetterBackoffMaxMs` | 60000 | Cap on the backoff between dead-letter attempts |
561
-
562
- `run()` sweeps the store for due scopes every `sweepMs` (1000), up to `sweepLimit` (100) scopes per sweep.
563
-
564
- ## Agent skill
565
-
566
- The repo ships an [Agent Skill](skills/attend/SKILL.md) that leads your coding agent through wiring attend into your app. It works out which events the app emits, which of them are signals, and which deserve which reaction, then wires it with one small module and a few call sites at your existing event chokepoints. It tells you in a few lines what it will do, asks one batch of questions with a recommended default for each, and tunes the act threshold from a replay of your own events. To install it in Claude Code:
160
+ The repo ships an [Agent Skill](skills/attend/SKILL.md) that leads your coding agent through wiring attend into your app. It works out which events the app emits, which of them are signals, and which deserve which reaction. It tells you in a few lines what it will do, asks one batch of questions with a recommended default for each, and wires it in with one small module and a few call sites at your existing event chokepoints. To install it in Claude Code:
567
161
 
568
162
  ```text
569
163
  /plugin marketplace add acquiredintelligence/attend
570
164
  /plugin install attend@attend
571
165
  ```
572
166
 
573
- For Codex or Cursor, copy `skills/attend` into your project's `.agents/skills/`. The npm package includes the skill at `node_modules/attend/skills/attend`. The third-party Vercel Labs CLI also installs it with `npx skills add acquiredintelligence/attend`. That CLI is not part of this project.
167
+ For Codex or Cursor, copy `skills/attend` into your project's `.agents/skills/`. The npm package includes the skill at `node_modules/@acquiredintelligence/attend/skills/attend`. The third-party Vercel Labs CLI also installs it with `npx skills add acquiredintelligence/attend`. That CLI is not part of this project.
574
168
 
575
169
  ## Status
576
170