@acquiredintelligence/attend 0.0.0-stage → 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.
Files changed (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -2
  3. package/dist/attend.d.ts +30 -0
  4. package/dist/attend.d.ts.map +1 -0
  5. package/dist/attend.js +464 -0
  6. package/dist/attend.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +4 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/core/ids.d.ts +12 -0
  12. package/dist/core/ids.d.ts.map +1 -0
  13. package/dist/core/ids.js +97 -0
  14. package/dist/core/ids.js.map +1 -0
  15. package/dist/core/reducer.d.ts +26 -0
  16. package/dist/core/reducer.d.ts.map +1 -0
  17. package/dist/core/reducer.js +437 -0
  18. package/dist/core/reducer.js.map +1 -0
  19. package/dist/core/seen.d.ts +22 -0
  20. package/dist/core/seen.d.ts.map +1 -0
  21. package/dist/core/seen.js +61 -0
  22. package/dist/core/seen.js.map +1 -0
  23. package/dist/core/state.d.ts +12 -0
  24. package/dist/core/state.d.ts.map +1 -0
  25. package/dist/core/state.js +90 -0
  26. package/dist/core/state.js.map +1 -0
  27. package/dist/core/step.d.ts +14 -0
  28. package/dist/core/step.d.ts.map +1 -0
  29. package/dist/core/step.js +17 -0
  30. package/dist/core/step.js.map +1 -0
  31. package/dist/core/threshold.d.ts +40 -0
  32. package/dist/core/threshold.d.ts.map +1 -0
  33. package/dist/core/threshold.js +72 -0
  34. package/dist/core/threshold.js.map +1 -0
  35. package/dist/core/types.d.ts +704 -0
  36. package/dist/core/types.d.ts.map +1 -0
  37. package/dist/core/types.js +24 -0
  38. package/dist/core/types.js.map +1 -0
  39. package/dist/core/window.d.ts +38 -0
  40. package/dist/core/window.d.ts.map +1 -0
  41. package/dist/core/window.js +322 -0
  42. package/dist/core/window.js.map +1 -0
  43. package/dist/decisions.d.ts +53 -0
  44. package/dist/decisions.d.ts.map +1 -0
  45. package/dist/decisions.js +115 -0
  46. package/dist/decisions.js.map +1 -0
  47. package/dist/errors.d.ts +36 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +53 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/index.d.ts +10 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +4 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/internal/adapter.d.ts +21 -0
  56. package/dist/internal/adapter.d.ts.map +1 -0
  57. package/dist/internal/adapter.js +62 -0
  58. package/dist/internal/adapter.js.map +1 -0
  59. package/dist/internal/assert.d.ts +5 -0
  60. package/dist/internal/assert.d.ts.map +1 -0
  61. package/dist/internal/assert.js +14 -0
  62. package/dist/internal/assert.js.map +1 -0
  63. package/dist/internal/clock.d.ts +8 -0
  64. package/dist/internal/clock.d.ts.map +1 -0
  65. package/dist/internal/clock.js +38 -0
  66. package/dist/internal/clock.js.map +1 -0
  67. package/dist/internal/http.d.ts +42 -0
  68. package/dist/internal/http.d.ts.map +1 -0
  69. package/dist/internal/http.js +122 -0
  70. package/dist/internal/http.js.map +1 -0
  71. package/dist/internal/images.d.ts +18 -0
  72. package/dist/internal/images.d.ts.map +1 -0
  73. package/dist/internal/images.js +75 -0
  74. package/dist/internal/images.js.map +1 -0
  75. package/dist/internal/json.d.ts +3 -0
  76. package/dist/internal/json.d.ts.map +1 -0
  77. package/dist/internal/json.js +5 -0
  78. package/dist/internal/json.js.map +1 -0
  79. package/dist/internal/prompt.d.ts +31 -0
  80. package/dist/internal/prompt.d.ts.map +1 -0
  81. package/dist/internal/prompt.js +149 -0
  82. package/dist/internal/prompt.js.map +1 -0
  83. package/dist/internal/shared.d.ts +12 -0
  84. package/dist/internal/shared.d.ts.map +1 -0
  85. package/dist/internal/shared.js +138 -0
  86. package/dist/internal/shared.js.map +1 -0
  87. package/dist/jev.d.ts +74 -0
  88. package/dist/jev.d.ts.map +1 -0
  89. package/dist/jev.js +156 -0
  90. package/dist/jev.js.map +1 -0
  91. package/dist/llm.d.ts +164 -0
  92. package/dist/llm.d.ts.map +1 -0
  93. package/dist/llm.js +79 -0
  94. package/dist/llm.js.map +1 -0
  95. package/dist/mcp.d.ts +130 -0
  96. package/dist/mcp.d.ts.map +1 -0
  97. package/dist/mcp.js +550 -0
  98. package/dist/mcp.js.map +1 -0
  99. package/dist/memory.d.ts +10 -0
  100. package/dist/memory.d.ts.map +1 -0
  101. package/dist/memory.js +50 -0
  102. package/dist/memory.js.map +1 -0
  103. package/dist/postgres.d.ts +25 -0
  104. package/dist/postgres.d.ts.map +1 -0
  105. package/dist/postgres.js +56 -0
  106. package/dist/postgres.js.map +1 -0
  107. package/dist/redis.d.ts +20 -0
  108. package/dist/redis.d.ts.map +1 -0
  109. package/dist/redis.js +83 -0
  110. package/dist/redis.js.map +1 -0
  111. package/dist/server/compat.d.ts +13 -0
  112. package/dist/server/compat.d.ts.map +1 -0
  113. package/dist/server/compat.js +5 -0
  114. package/dist/server/compat.js.map +1 -0
  115. package/dist/server/cursor.d.ts +15 -0
  116. package/dist/server/cursor.d.ts.map +1 -0
  117. package/dist/server/cursor.js +50 -0
  118. package/dist/server/cursor.js.map +1 -0
  119. package/dist/server/events.d.ts +42 -0
  120. package/dist/server/events.d.ts.map +1 -0
  121. package/dist/server/events.js +189 -0
  122. package/dist/server/events.js.map +1 -0
  123. package/dist/server/fanout.d.ts +28 -0
  124. package/dist/server/fanout.d.ts.map +1 -0
  125. package/dist/server/fanout.js +184 -0
  126. package/dist/server/fanout.js.map +1 -0
  127. package/dist/server/guard.d.ts +19 -0
  128. package/dist/server/guard.d.ts.map +1 -0
  129. package/dist/server/guard.js +27 -0
  130. package/dist/server/guard.js.map +1 -0
  131. package/dist/server/http.d.ts +4 -0
  132. package/dist/server/http.d.ts.map +1 -0
  133. package/dist/server/http.js +323 -0
  134. package/dist/server/http.js.map +1 -0
  135. package/dist/server/ingest.d.ts +4 -0
  136. package/dist/server/ingest.d.ts.map +1 -0
  137. package/dist/server/ingest.js +400 -0
  138. package/dist/server/ingest.js.map +1 -0
  139. package/dist/server/install.d.ts +5 -0
  140. package/dist/server/install.d.ts.map +1 -0
  141. package/dist/server/install.js +91 -0
  142. package/dist/server/install.js.map +1 -0
  143. package/dist/server/poll.d.ts +4 -0
  144. package/dist/server/poll.d.ts.map +1 -0
  145. package/dist/server/poll.js +45 -0
  146. package/dist/server/poll.js.map +1 -0
  147. package/dist/server/serve.d.ts +19 -0
  148. package/dist/server/serve.d.ts.map +1 -0
  149. package/dist/server/serve.js +238 -0
  150. package/dist/server/serve.js.map +1 -0
  151. package/dist/server/sessions.d.ts +14 -0
  152. package/dist/server/sessions.d.ts.map +1 -0
  153. package/dist/server/sessions.js +54 -0
  154. package/dist/server/sessions.js.map +1 -0
  155. package/dist/server/stream.d.ts +11 -0
  156. package/dist/server/stream.d.ts.map +1 -0
  157. package/dist/server/stream.js +216 -0
  158. package/dist/server/stream.js.map +1 -0
  159. package/dist/server/tools.d.ts +22 -0
  160. package/dist/server/tools.d.ts.map +1 -0
  161. package/dist/server/tools.js +55 -0
  162. package/dist/server/tools.js.map +1 -0
  163. package/dist/server/types.d.ts +351 -0
  164. package/dist/server/types.d.ts.map +1 -0
  165. package/dist/server/types.js +5 -0
  166. package/dist/server/types.js.map +1 -0
  167. package/dist/server.d.ts +11 -0
  168. package/dist/server.d.ts.map +1 -0
  169. package/dist/server.js +10 -0
  170. package/dist/server.js.map +1 -0
  171. package/dist/shell/budget.d.ts +4 -0
  172. package/dist/shell/budget.d.ts.map +1 -0
  173. package/dist/shell/budget.js +5 -0
  174. package/dist/shell/budget.js.map +1 -0
  175. package/dist/shell/copy.d.ts +20 -0
  176. package/dist/shell/copy.d.ts.map +1 -0
  177. package/dist/shell/copy.js +73 -0
  178. package/dist/shell/copy.js.map +1 -0
  179. package/dist/shell/decisions.d.ts +6 -0
  180. package/dist/shell/decisions.d.ts.map +1 -0
  181. package/dist/shell/decisions.js +42 -0
  182. package/dist/shell/decisions.js.map +1 -0
  183. package/dist/shell/read.d.ts +7 -0
  184. package/dist/shell/read.d.ts.map +1 -0
  185. package/dist/shell/read.js +21 -0
  186. package/dist/shell/read.js.map +1 -0
  187. package/dist/shell/scheduler.d.ts +4 -0
  188. package/dist/shell/scheduler.d.ts.map +1 -0
  189. package/dist/shell/scheduler.js +90 -0
  190. package/dist/shell/scheduler.js.map +1 -0
  191. package/dist/shell/scope.d.ts +27 -0
  192. package/dist/shell/scope.d.ts.map +1 -0
  193. package/dist/shell/scope.js +343 -0
  194. package/dist/shell/scope.js.map +1 -0
  195. package/dist/shell/types.d.ts +168 -0
  196. package/dist/shell/types.d.ts.map +1 -0
  197. package/dist/shell/types.js +2 -0
  198. package/dist/shell/types.js.map +1 -0
  199. package/dist/sqlite.d.ts +14 -0
  200. package/dist/sqlite.d.ts.map +1 -0
  201. package/dist/sqlite.js +39 -0
  202. package/dist/sqlite.js.map +1 -0
  203. package/dist/step.d.ts +18 -0
  204. package/dist/step.d.ts.map +1 -0
  205. package/dist/step.js +14 -0
  206. package/dist/step.js.map +1 -0
  207. package/dist/store/memory.d.ts +12 -0
  208. package/dist/store/memory.d.ts.map +1 -0
  209. package/dist/store/memory.js +264 -0
  210. package/dist/store/memory.js.map +1 -0
  211. package/dist/store/postgres.d.ts +24 -0
  212. package/dist/store/postgres.d.ts.map +1 -0
  213. package/dist/store/postgres.js +364 -0
  214. package/dist/store/postgres.js.map +1 -0
  215. package/dist/store/redis.d.ts +12 -0
  216. package/dist/store/redis.d.ts.map +1 -0
  217. package/dist/store/redis.js +540 -0
  218. package/dist/store/redis.js.map +1 -0
  219. package/dist/store/sqlite-worker.d.ts +91 -0
  220. package/dist/store/sqlite-worker.d.ts.map +1 -0
  221. package/dist/store/sqlite-worker.js +322 -0
  222. package/dist/store/sqlite-worker.js.map +1 -0
  223. package/dist/store/sqlite.d.ts +34 -0
  224. package/dist/store/sqlite.d.ts.map +1 -0
  225. package/dist/store/sqlite.js +238 -0
  226. package/dist/store/sqlite.js.map +1 -0
  227. package/dist/store/types.d.ts +137 -0
  228. package/dist/store/types.d.ts.map +1 -0
  229. package/dist/store/types.js +2 -0
  230. package/dist/store/types.js.map +1 -0
  231. package/dist/testing/conformance.d.ts +13 -0
  232. package/dist/testing/conformance.d.ts.map +1 -0
  233. package/dist/testing/conformance.js +542 -0
  234. package/dist/testing/conformance.js.map +1 -0
  235. package/dist/testing/wire.d.ts +16 -0
  236. package/dist/testing/wire.d.ts.map +1 -0
  237. package/dist/testing/wire.js +304 -0
  238. package/dist/testing/wire.js.map +1 -0
  239. package/dist/testing.d.ts +113 -0
  240. package/dist/testing.d.ts.map +1 -0
  241. package/dist/testing.js +655 -0
  242. package/dist/testing.js.map +1 -0
  243. package/dist/types.d.ts +5 -0
  244. package/dist/types.d.ts.map +1 -0
  245. package/dist/types.js +5 -0
  246. package/dist/types.js.map +1 -0
  247. package/package.json +130 -4
  248. package/skills/attend/SKILL.md +231 -0
  249. package/skills/attend/references/judges.md +283 -0
  250. package/skills/attend/references/kinds.md +62 -0
  251. package/skills/attend/references/mcp.md +132 -0
  252. package/skills/attend/references/sinks.md +73 -0
  253. package/skills/attend/references/stores.md +90 -0
  254. package/skills/attend/references/testing.md +120 -0
@@ -0,0 +1,283 @@
1
+ # Judges
2
+
3
+ A judge is `(input: JudgeInput, signal: AbortSignal) => Promise<Judgment>`.
4
+
5
+ ## The input
6
+
7
+ | Field | Holds |
8
+ |---|---|
9
+ | `scope` | The scope key |
10
+ | `window` | The settled window: `events` with kinds, `reason`, `waits`, `dropped`, `gap` |
11
+ | `routes` | The routes on offer, each with `criterion` when `AttendConfig.criteria` sets one. `wait` is absent when the window may not wait. |
12
+ | `guidance` | `AttendConfig.guidance`, when set |
13
+ | `predicates` | Yes-or-no questions, as `{ name, question }`, from `AttendConfig.predicates` and `context()` |
14
+ | `facts` | `context(scope, signal).facts`, fetched on every call |
15
+ | `images` | What `AttendConfig.capture` returned for this call, when it returned any |
16
+ | `recent` | The scope's last verdicts, oldest first, at most `recentCount` |
17
+ | `now` | attend's clock |
18
+
19
+ ## The answer
20
+
21
+ Return `{ route, probabilities?, predicates?, confidence? }`.
22
+
23
+ - `route` must be one of `input.routes`. Any other route fails the attempt, and the attempt is retried.
24
+ - `probabilities` maps offered routes to numbers from 0 to 1. attend sets routes that were not offered to 0 and renormalises the rest. When they sum to 0, the chosen route gets 1.
25
+ - `predicates` maps predicate names to numbers from 0 to 1. attend drops values outside that range.
26
+ - `confidence` is a number from 0 to 1 when the judge has one, such as a classifier's confidence in the route. It reaches the sink as `decision.confidence`, null when absent.
27
+ - Throw to retry with backoff from `retryBackoffMs`. Throw `PermanentError` from `@acquiredintelligence/attend` to dead-letter the window at once.
28
+ - Stop when `signal` aborts. attend aborts the call after `judgeTimeoutMs`.
29
+
30
+ ## llmJudge
31
+
32
+ `llmJudge({ complete })` from `@acquiredintelligence/attend/llm` writes the system prompt, renders the input as compact JSON, builds a strict JSON Schema for the answer, and validates the reply. `complete({ system, prompt, schema, signal })` makes one structured-output call and resolves with the parsed JSON.
33
+
34
+ Claude:
35
+
36
+ ```ts
37
+ import Anthropic from '@anthropic-ai/sdk';
38
+ import { PermanentError } from '@acquiredintelligence/attend';
39
+ import { llmJudge } from '@acquiredintelligence/attend/llm';
40
+
41
+ const anthropic = new Anthropic();
42
+
43
+ export const judge = llmJudge({
44
+ complete: async ({ system, prompt, images, schema, signal }) => {
45
+ const res = await anthropic.messages.create(
46
+ {
47
+ model: 'claude-opus-5-5',
48
+ max_tokens: 1024,
49
+ system,
50
+ messages: [
51
+ {
52
+ role: 'user',
53
+ content: [
54
+ { type: 'text', text: prompt },
55
+ ...images.map((image) => ({ type: 'image' as const, source: { type: 'base64' as const, media_type: image.mimeType, data: image.data } })),
56
+ ],
57
+ },
58
+ ],
59
+ output_config: { effort: 'low', format: { type: 'json_schema', schema } },
60
+ },
61
+ { signal },
62
+ );
63
+ if (res.stop_reason === 'refusal') throw new PermanentError('judge refused');
64
+ const text = res.content.find((b) => b.type === 'text');
65
+ return JSON.parse(text?.type === 'text' ? text.text : 'null');
66
+ },
67
+ });
68
+ ```
69
+
70
+ - Use `claude-opus-5-5`, or `claude-haiku-5-5` for a cheaper judge. Write the model id without a date suffix.
71
+ - Request structured output with `output_config.format`. Keep `effort: 'low'` for a classifier, and leave `thinking` and `tool_choice` out.
72
+ - Check `stop_reason === 'refusal'` before you read `content`. A refusal arrives with HTTP 200.
73
+ - Images go as `image` blocks with a base64 `source`. PNG, JPEG, GIF and WEBP are accepted, at most 10 MB each and 8000 px per side (2000 px when a request has more than 20 images).
74
+
75
+ OpenAI Responses API:
76
+
77
+ ```ts
78
+ import OpenAI from 'openai';
79
+ import { PermanentError } from '@acquiredintelligence/attend';
80
+ import { llmJudge } from '@acquiredintelligence/attend/llm';
81
+
82
+ const openai = new OpenAI();
83
+
84
+ export const judge = llmJudge({
85
+ complete: async ({ system, prompt, images, schema, signal }) => {
86
+ const res = await openai.responses.create(
87
+ {
88
+ model: '<model id>',
89
+ instructions: system,
90
+ input: [
91
+ {
92
+ role: 'user',
93
+ content: [
94
+ { type: 'input_text', text: prompt },
95
+ ...images.map((image) => ({ type: 'input_image' as const, image_url: `data:${image.mimeType};base64,${image.data}`, detail: 'auto' as const })),
96
+ ],
97
+ },
98
+ ],
99
+ text: { format: { type: 'json_schema', name: 'judgment', schema, strict: true } },
100
+ },
101
+ { signal },
102
+ );
103
+ if (res.output.some((item) => item.type === 'message' && item.content.some((part) => part.type === 'refusal'))) throw new PermanentError('judge refused');
104
+ return JSON.parse(res.output_text);
105
+ },
106
+ });
107
+ ```
108
+
109
+ - A refusal is a `refusal` content part inside the `message` output item, not a status code. `response.status === 'incomplete'` means the answer was cut off; treat it as a retryable failure.
110
+ - Images go as `input_image` parts with a `data:` URL. PNG, JPEG, WEBP and non-animated GIF are accepted.
111
+
112
+ Gemini, through `@google/genai`. This snippet was written without the package installed, so check `generateContent` and the config fields against the version you use:
113
+
114
+ ```ts
115
+ import { GoogleGenAI } from '@google/genai';
116
+ import { PermanentError } from '@acquiredintelligence/attend';
117
+ import { llmJudge } from '@acquiredintelligence/attend/llm';
118
+
119
+ const ai = new GoogleGenAI({});
120
+
121
+ export const judge = llmJudge({
122
+ complete: async ({ system, prompt, images, schema, signal }) => {
123
+ const res = await ai.models.generateContent({
124
+ model: '<model id>',
125
+ contents: [{ role: 'user', parts: [{ text: prompt }, ...images.map((image) => ({ inlineData: { mimeType: image.mimeType, data: image.data } }))] }],
126
+ config: { systemInstruction: system, responseMimeType: 'application/json', responseJsonSchema: schema, abortSignal: signal },
127
+ });
128
+ const finish = res.candidates?.[0]?.finishReason;
129
+ if (res.promptFeedback?.blockReason !== undefined || (finish !== undefined && finish !== 'STOP' && finish !== 'MAX_TOKENS')) throw new PermanentError('judge refused');
130
+ return JSON.parse(res.text ?? 'null');
131
+ },
132
+ });
133
+ ```
134
+
135
+ - A blocked prompt has `promptFeedback.blockReason` and no candidates; a blocked answer has a `finishReason` such as `SAFETY`. Both are refusals. `MAX_TOKENS` is truncation, and the JSON parse then fails as a retryable error.
136
+ - Images go as `inlineData` parts. PNG, JPEG, WEBP, HEIC and HEIF are accepted; GIF is not. Keep the request under 20 MB.
137
+ - The JS SDK does not retry on its own. attend retries the judge call, so leave `httpOptions.retryOptions` unset.
138
+
139
+ Vercel AI SDK. This snippet was written without the `ai` package installed, so check `generateObject` and `jsonSchema` against the version you use:
140
+
141
+ ```ts
142
+ import { openai } from '@ai-sdk/openai';
143
+ import { generateObject, jsonSchema } from 'ai';
144
+ import { llmJudge } from '@acquiredintelligence/attend/llm';
145
+
146
+ export const judge = llmJudge({
147
+ complete: async ({ system, prompt, images, schema, signal }) => {
148
+ const { object } = await generateObject({
149
+ model: openai('<model id>'),
150
+ system,
151
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...images.map((image) => ({ type: 'image' as const, image: image.data, mediaType: image.mimeType }))] }],
152
+ schema: jsonSchema(schema),
153
+ abortSignal: signal,
154
+ });
155
+ return object;
156
+ },
157
+ });
158
+ ```
159
+
160
+ Options:
161
+
162
+ - `system` replaces `DEFAULT_SYSTEM`. attend appends the offered routes and their criteria either way.
163
+ - `criteria` sets per-route criteria. A criterion from `AttendConfig.criteria` wins, and `DEFAULT_CRITERIA` fills the rest.
164
+ - `render(input)` replaces the default user prompt, which is compact JSON of the guidance, the window, the facts, the recent routes and the predicate questions.
165
+
166
+ ## jevJudge
167
+
168
+ ```ts
169
+ import { jevJudge } from '@acquiredintelligence/attend/jev';
170
+
171
+ export const judge = jevJudge({ apiKey: process.env.TYPESAFE_API_KEY ?? '' });
172
+ ```
173
+
174
+ It calls TypeSafe's Jev at `POST /v1/systemone` with the user's own key. It sends one `choice` question keyed `route`, with each offered route's criterion as its option description, one `noul` question per predicate keyed by position, and the rendered state as JSON `state`. The answer's `probabilities` and `confidence` reach the judgment.
175
+
176
+ Options:
177
+
178
+ - `model`: `jev-latest` by default, which is the latest stable release (`jev-1.13.0` today). `jev-preview` is the newest release (`jev-1.14.0` today). `jev-1.14.0` and `jev-1.13.0` pin a version; pin one when thresholds on `confidence` are tuned.
179
+ - `endpoint`: `https://api.typesafe.ai/v1/systemone` by default. OpenRouter serves the same API at `https://openrouter.ai/api/v1/systemone`, and LiteLLM at its own host under the same path.
180
+ - `longContext: true` sends `TypeSafe-Danger-Zone-Long-Context: true`, which raises the per-request and per-question context from 64k and 32k tokens to 262k. It is slower and less accurate, so use it only when the state is long.
181
+ - `criteria`, `instructions` and `fetch`, as on `decisionsJudge`.
182
+ - `images: false` never sends images. `onImageError(error)` hears about every image dropped before a call and about a call retried without images. See [Images](#images).
183
+
184
+ ## decisionsJudge
185
+
186
+ ```ts
187
+ import { decisionsJudge } from '@acquiredintelligence/attend/decisions';
188
+
189
+ export const judge = decisionsJudge({ apiKey: process.env.OPENAI_API_KEY ?? '', model: '<model id>' });
190
+ ```
191
+
192
+ It sends one `choice` question named `route`, with each offered route's criterion as its description, and one `predicate` question per predicate. Pass `fetch` to run it on a custom transport, and `endpoint` to point it elsewhere. `images: false` and `onImageError` work as on `jevJudge`.
193
+
194
+ ## Errors from the hosted judges
195
+
196
+ Both `jevJudge` and `decisionsJudge` classify a response the same way:
197
+
198
+ | Response | Thrown | attend does |
199
+ |---|---|---|
200
+ | Network failure, timeout, 408, 429, 5xx, 529 | `Error` | Retries with backoff |
201
+ | 429 with a billing or quota code (`credit_balance_exhausted`, `organization_spend_limit_exceeded`, `project_spend_limit_exceeded`, `organization_usage_limit_exceeded`, `insufficient_quota`) | `PermanentError` | Dead-letters at once |
202
+ | Any other 4xx, including 400 for a request over the context length and 413 for one over the body limit | `PermanentError` | Dead-letters at once |
203
+ | A refusal of the route question (Decisions only; Jev has no refusals) | `PermanentError` | Dead-letters at once |
204
+ | A reply whose route was not offered | `Error` | Retries |
205
+
206
+ Every error from a response carries `status`, `requestId` (from `x-typesafe-request-id` or `x-request-id`) and `retryAfterMs` (from `retry-after-ms` or `Retry-After`, else null). attend's backoff does not read `retryAfterMs` yet; it is there for a wrapper that wants to.
207
+
208
+ Before a call, a duplicate predicate name throws `PermanentError`, and so does a Decisions call with more than 199 predicates, since the API takes 200 questions and the route is one.
209
+
210
+ ## Images
211
+
212
+ `createAttend({ capture })` adds images to every judge call:
213
+
214
+ ```ts
215
+ import { createAttend, memoryStore, routes } from '@acquiredintelligence/attend';
216
+ import type { JudgeImage } from '@acquiredintelligence/attend';
217
+
218
+ declare function screenshot(scope: string, signal: AbortSignal): Promise<JudgeImage>;
219
+
220
+ export const attend = createAttend({
221
+ store: memoryStore(),
222
+ kind: () => 'closing',
223
+ judge: async () => ({ route: 'queue' }),
224
+ sink: routes({ interrupt: async () => {}, queue: async () => {}, note: async () => {}, ask: async () => {} }),
225
+ deadLetter: async () => {},
226
+ capture: (scope, signal) => screenshot(scope, signal),
227
+ captureTimeoutMs: 500,
228
+ });
229
+ ```
230
+
231
+ - `capture(scope, signal)` returns one `JudgeImage`, a list, or null. A `JudgeImage` is `{ data, mimeType }` with `data` as base64 and no `data:` prefix, and `mimeType` one of `image/png`, `image/jpeg`, `image/webp` or `image/gif`.
232
+ - A capture that throws or outlives `captureTimeoutMs` (default 500) never fails the judge call. The judge runs without images, and `observe` sees one `captureFailed` trace with the message.
233
+ - `jevJudge` sends only PNG and JPEG, each side 280 to 1568 px, at most 16 MiB per file and 32 MiB in all, and drops anything else before the call. The provider never resizes, so capture at a size inside those limits. Images on this provider are experimental, and a request it still rejects over its images (400, 413, 415 or 422) is retried once without them.
234
+ - `decisionsJudge` sends up to 128 images of any format as `input_image` data URLs, and retries once without them on the same statuses, since the API documents no format or size limits.
235
+ - `llmJudge` hands the images to `complete` as `images`. The snippets above place them after the prompt in each model's own block format.
236
+
237
+ ## A function judge
238
+
239
+ Write a plain function when a rule decides. Read `input.routes` before you answer `wait`:
240
+
241
+ ```ts
242
+ import type { Judge } from '@acquiredintelligence/attend';
243
+
244
+ export const judge: Judge = async ({ window, routes }) => {
245
+ const has = (name: string) => window.events.some((e) => e.name === name);
246
+ if (window.reason === 'flush') return { route: 'interrupt' };
247
+ if (has('build.failed')) return { route: routes.some((o) => o.route === 'wait') ? 'wait' : 'queue' };
248
+ return { route: 'skip' };
249
+ };
250
+ ```
251
+
252
+ Judges compose as functions. Wrap a model judge so rules answer first:
253
+
254
+ ```ts
255
+ import type { Judge } from '@acquiredintelligence/attend';
256
+
257
+ export const withShortcut =
258
+ (judge: Judge): Judge =>
259
+ async (input, signal) =>
260
+ input.window.reason === 'flush' ? { route: 'interrupt' } : judge(input, signal);
261
+ ```
262
+
263
+ A hosted classifier is a judge too: send `input` as JSON, map its answer to `{ route }`, throw on retryable errors, and throw `PermanentError` on the rest.
264
+
265
+ ## Configuring what the judge sees
266
+
267
+ ```ts
268
+ import { createAttend, memoryStore, routes } from '@acquiredintelligence/attend';
269
+
270
+ export const attend = createAttend({
271
+ store: memoryStore(),
272
+ kind: () => 'closing',
273
+ judge: async () => ({ route: 'queue' }),
274
+ sink: routes({ interrupt: async () => {}, queue: async () => {}, note: async () => {}, ask: async () => {} }),
275
+ deadLetter: async () => {},
276
+ guidance: 'An on-call agent for the payments service. Its user wants pages for customer impact only.',
277
+ criteria: { interrupt: 'Customers are failing to pay right now.' },
278
+ predicates: { asked: 'Did the user ask the agent to handle this?' },
279
+ context: async (scope) => ({ facts: { midTurn: false, scope } }),
280
+ });
281
+ ```
282
+
283
+ Each predicate's answer arrives on `decision.predicates[name]` as a probability. A sink never sees `skip`, so to wake the agent on a skipped window whose predicate is high, wrap the judge: call it, then replace `skip` with another route when the predicate passes your threshold.
@@ -0,0 +1,62 @@
1
+ # Settle rules
2
+
3
+ How a scope's open window settles, exactly. All times are on attend's clock at ingest (`entry.seenAt`), never the source's `event.at`. Defaults are in `DEFAULT_POLICY`, exported from `@acquiredintelligence/attend`.
4
+
5
+ `closing`, `flush`, `continuous` and `passive` are acts: things that happen in the scope and can call for the agent, whether the user did them or an outside party did. `consequence` is output that followed from an act, and `context` is the agent's own earlier output.
6
+
7
+ ## Deadlines
8
+
9
+ Every event in the open window can set a deadline. The window settles at the earliest one, with that deadline's reason.
10
+
11
+ | Deadline | At | Reason |
12
+ |---|---|---|
13
+ | A `flush` event | Its arrival | `flush` |
14
+ | The last `closing` event | Its arrival plus `closingGraceMs` | `closing` |
15
+ | The last `continuous` event | Its arrival plus `streamGapMs` | `stream_quiet` |
16
+ | The last `passive` event | Its arrival plus `passiveQuietMs` | `passive_quiet` |
17
+ | The first act (`closing`, `continuous` or `passive`) | Its arrival plus `maxWindowMs` | `age` |
18
+ | `attend.flush(scope)` | The call | `forced` |
19
+ | More than `maxEvents` events left after shedding | The last arrival | `overflow` |
20
+
21
+ - A later closing, continuous or passive event moves its own deadline later. A steady run of continuous events holds the window open until they stop, or until the `age` deadline.
22
+ - A window with no flush and no act has no deadline. It never settles.
23
+ - After a `flush` or `forced` settle, the judge is not offered `wait`.
24
+
25
+ ## Shedding
26
+
27
+ - When the window holds more than `maxEvents` events, attend drops its oldest `consequence` and `context` events, adds them to `window.dropped`, and traces `dropped` with `why: 'overflow'`. If only acts and flushes are left and the window is still too big, it settles with reason `overflow`.
28
+ - `consequence` and `context` events that arrived before the first act and are older than `maxWindowMs` are dropped the same way, with `why: 'age'`.
29
+ - attend never drops an act or a flush.
30
+
31
+ ## One judge call at a time
32
+
33
+ Each scope has one judge slot. While a judge call runs, new events collect in the open window, and that window cannot settle. When the judge answers, a window whose deadline has already passed settles at once.
34
+
35
+ ## Wait and merge
36
+
37
+ - `wait` moves the settled window into a held state until `waitHorizonMs` from the answer.
38
+ - If the open window settles before the horizon, the held window and the new one merge, held events first. The merged window has a new id, because the id is a hash of all its event ids, and it keeps the held window's `waits` count. The judge is offered `wait` again only while `waits` is below `maxWaits` and the reason is neither `flush` nor `forced`.
39
+ - At the horizon, attend judges the held window together with everything in the open window, consequences included, with `wait` withheld. The merged window has a new id.
40
+ - `attend.flush(scope)` on a scope that holds only a waiting window judges it at the runner's next tick, with `wait` withheld.
41
+
42
+ ## What the judge sees
43
+
44
+ `input.window` carries:
45
+
46
+ - `id`, a hash of the scope and the event ids, which becomes the decision id;
47
+ - `events`, oldest first, each with its `kind` and `seenAt`;
48
+ - `reason`, the settle reason from the table above;
49
+ - `waits`, how many times this window was held;
50
+ - `dropped`, how many consequence and context events were shed;
51
+ - `gap`, true when a source reported lost events, such as MCP `truncated: true`;
52
+ - `settledAt`.
53
+
54
+ ## Kinds by agent
55
+
56
+ These are starting points. Run the question sequence in `SKILL.md` on your own event names.
57
+
58
+ - **Coding agent on a repo.** A comment that names the agent is `flush`. A merged pull request, a failed build and a finished deploy are `closing`. CI log lines and status changes are `consequence`. The agent's own commits and messages are `context`.
59
+ - **Voice agent.** A final transcript is `flush`. Partial transcripts are `continuous`. Tool results are `consequence`. The agent's own speech is `context`.
60
+ - **Inbox agent.** A message to the agent's address is `flush`. A new email that the agent should triage with no user act is `closing`. The user opening an email is `passive`, and the user sending a reply is `closing`. Delivery receipts are `consequence`.
61
+ - **On-call agent.** A page or a fired alert is `closing`. Metric samples and log lines are `consequence`. An incident-channel message that mentions the agent is `flush`.
62
+ - **Gesture app.** Hand and pointer motion are `continuous`. A gaze dwell is `passive`. Grabbing or releasing an object is `closing`. Physics and render results are `consequence`.
@@ -0,0 +1,132 @@
1
+ # MCP Events sources
2
+
3
+ `mcpEventsSource(client, options)` from `@acquiredintelligence/attend/mcp` turns one MCP Events subscription into a `Source`. It needs the peer dependencies `@modelcontextprotocol/sdk` and `zod` (3.25 or later, or 4).
4
+
5
+ ## Wire it
6
+
7
+ ```ts
8
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
10
+ import { createAttend, memoryStore, routes } from '@acquiredintelligence/attend';
11
+ import { mcpEventsSource } from '@acquiredintelligence/attend/mcp';
12
+
13
+ const client = new Client({ name: 'my-agent', version: '1.0.0' });
14
+ await client.connect(new StdioClientTransport({ command: 'node', args: ['./ci-events-server.js'] }));
15
+
16
+ const attend = createAttend({
17
+ store: memoryStore(),
18
+ kind: (event) => (event.name === 'comment.created' ? 'flush' : 'consequence'),
19
+ judge: async () => ({ route: 'queue' }),
20
+ sink: routes({ interrupt: async () => {}, queue: async () => {}, note: async () => {}, ask: async () => {} }),
21
+ deadLetter: async () => {},
22
+ });
23
+
24
+ const controller = new AbortController();
25
+ const source = mcpEventsSource(client, { name: 'repo.activity', arguments: { repo: 'acme/shop' } });
26
+ await Promise.all([attend.run({ signal: controller.signal }), attend.attach('repo:acme/shop', source, { signal: controller.signal })]);
27
+ ```
28
+
29
+ - One subscription feeds one scope. Pick the scope from the subscription's `arguments`, such as one scope per repo. A scope can take several sources; each keeps its own cursor under its `Source.key`, which for this source is `mcp:<name>:<arguments as sorted JSON>`.
30
+ - Several subscriptions can share one `Client`. attend installs one notification handler for each of the five `notifications/events/*` methods the first time it uses a Client, records the JSON-RPC id of each `events/stream` request, and routes by `_meta["io.modelcontextprotocol/subscriptionId"]`. Do not replace those handlers.
31
+ - Attach a given subscription to a scope from one process. Every batch commits the server's cursor with its events, and `attach` resumes from the committed cursor after a restart.
32
+
33
+ ## Options
34
+
35
+ | Option | Default | Does |
36
+ |---|---|---|
37
+ | `name` | required | The event name from `events/list` |
38
+ | `arguments` | none | The subscription's arguments |
39
+ | `mode` | `'auto'` | `'auto'` streams when `events/list` lists `push` delivery for the event and polls otherwise. `'stream'` and `'poll'` force one. |
40
+ | `maxAgeMs` | none | Passed through. Bounds replay when resuming from a cursor. |
41
+ | `maxEvents` | none | Passed through as the poll page size |
42
+ | `pollMs` | 10000 | Poll interval when the server sends no `nextPollMs` |
43
+ | `maxBuffered` | 1000 | Stream notifications that may wait while an earlier one persists. Past it, the source drops what it holds, reports `overflow`, and reopens from the committed cursor so the server resends them. |
44
+ | `idleTimeoutMs` | 300000 | How long a stream may go without any notification, heartbeats included. Past it, the source reports `idle` and reopens from the committed cursor. |
45
+ | `clock` | the system clock | Pass the test's fake clock here too |
46
+ | `onError` | ignore | Receives `{ kind: 'stream' }` for `notifications/events/error`, `{ kind: 'malformed' }` for a payload that failed validation, `{ kind: 'overflow' }`, `{ kind: 'idle' }`, and `{ kind: 'unrouted' }` for a notification under a subscription id no stream here opened |
47
+
48
+ ## What the source does with the protocol
49
+
50
+ - `eventId` becomes `Event.id`, the occurrence's `name` becomes `Event.name`, `timestamp` becomes `Event.at`, and `data` becomes `Event.data`. `Event.text` stays unset, so `defaultNotice` prints the name and the JSON data.
51
+ - `truncated: true` on a poll result or on `notifications/events/active` sets `window.gap`.
52
+ - A poll result with `hasMore` polls again at once. Otherwise the source waits `nextPollMs`, or `pollMs`.
53
+ - A malformed occurrence goes to `onError` and is skipped. Its cursor still advances.
54
+ - A `notifications/events/terminated`, a stream request that returns, an overflow or an idle stream reopens the stream from the committed cursor after one second.
55
+
56
+ ## Errors
57
+
58
+ `events/list`, `events/poll` and `events/stream` errors all follow this table.
59
+
60
+ | Error | The source |
61
+ |---|---|
62
+ | `-32025` ResourceExhausted, or a request timeout | Throws it as a plain error. `attach` backs off through `retryBackoffMs` (the last rung repeated), traces `retrying` with stage `source`, and resubscribes from the committed cursor. |
63
+ | `-32026` Unsupported on `events/stream` | Switches to `events/poll` in `auto` mode. In `stream` mode it ends the subscription with a `PermanentError`. |
64
+ | `-32000` ConnectionClosed | The Client's transport closed. `attach` rejects with a `PermanentError` that says to reconnect the Client and attach again. |
65
+ | `-32023` NotFound, `-32024` Forbidden, `-32027`, any other code | Ends the subscription. `attach` rejects with a `PermanentError` whose `cause` is the SDK error. |
66
+
67
+ A rejected `attach` means the server refused the subscription or the Client closed. Reconnecting the Client and attaching again fixes the second; retrying the first gets the same answer, so log it and alert.
68
+
69
+ ## Filter events before they reach the scope
70
+
71
+ `kind` cannot drop an event. To drop noise such as heartbeats, wrap the source and keep its key:
72
+
73
+ ```ts
74
+ import type { Event, Source } from '@acquiredintelligence/attend';
75
+
76
+ export const only = (source: Source, keep: (event: Event) => boolean): Source => ({
77
+ key: source.key,
78
+ subscribe: (cursor, emit, signal) => source.subscribe(cursor, (batch) => emit({ ...batch, events: batch.events.filter(keep) }), signal),
79
+ });
80
+ ```
81
+
82
+ The batch keeps its cursor, so dropped events never come back.
83
+
84
+ ## Test against an in-memory server
85
+
86
+ A poll-only server over `InMemoryTransport` keeps a source test deterministic on a fake clock:
87
+
88
+ ```ts
89
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
90
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
91
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
92
+ import type { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js';
93
+ import { z } from 'zod/v4';
94
+
95
+ export type Occurrence = { eventId: string; name: string; timestamp: string; data: Record<string, string | number | boolean> };
96
+
97
+ const ListEvents = z.object({ method: z.literal('events/list') });
98
+ const PollEvents = z.object({ method: z.literal('events/poll'), params: z.object({ name: z.string(), cursor: z.string().nullable() }) });
99
+
100
+ /** A client connected to a server that serves `occurrences` from one poll event. Push to the array to publish more. */
101
+ export async function pollServerClient(event: string, occurrences: Occurrence[]): Promise<Client> {
102
+ const capabilities: ServerCapabilities & { events: { listChanged: boolean } } = { events: { listChanged: true } };
103
+ const server = new Server({ name: 'test-events', version: '0.0.0' }, { capabilities });
104
+ server.setRequestHandler(ListEvents, () => ({ events: [{ name: event, delivery: ['poll'], inputSchema: { type: 'object' }, payloadSchema: { type: 'object' } }] }));
105
+ server.setRequestHandler(PollEvents, (request) => {
106
+ const from = Number(request.params.cursor ?? 0);
107
+ return { events: occurrences.slice(from), cursor: String(occurrences.length), truncated: false, hasMore: false, nextPollMs: 1000 };
108
+ });
109
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
110
+ await server.connect(serverTransport);
111
+ const client = new Client({ name: 'test-agent', version: '0.0.0' });
112
+ await client.connect(clientTransport);
113
+ return client;
114
+ }
115
+ ```
116
+
117
+ Attach `mcpEventsSource(client, { name, clock })` with the same fake clock as `createAttend`. The client and the server talk through promise callbacks, so call `drain()` from `@acquiredintelligence/attend/testing` about four times after you attach, and again after each poll interval you advance, before you assert. Then advance past the settle deadline.
118
+
119
+ ## Typing note
120
+
121
+ With `exactOptionalPropertyTypes` on, SDK 1.32's `StreamableHTTPClientTransport` does not typecheck as a `Transport`, because its `sessionId` getter can return `undefined`. `StdioClientTransport` and `InMemoryTransport` typecheck.
122
+
123
+ ## Serving decisions: attend serve
124
+
125
+ `npx attend serve` runs attend as a sidecar. Events go in as NDJSON on `POST /v1/ingest`, and judged decisions come out on `/mcp` as MCP Events. Raw events never cross MCP.
126
+
127
+ ```sh
128
+ npm install @acquiredintelligence/attend @modelcontextprotocol/server zod
129
+ npx attend serve --store sqlite:attend.db --judge jev --kinds kinds.json --act-threshold 0.4
130
+ ```
131
+
132
+ `kinds.json` maps event names to kinds, such as `{"message.created":"flush","build.failed":"closing"}`. Flags and their environment variables are in the README. Subscribe to `attention.decision` with `events/stream`, poll it with `events/poll`, or call the `attend_wait` tool in a loop from a host that cannot subscribe. In TypeScript, `decisionFeed` from `@acquiredintelligence/attend/mcp` keeps the cursor. Use `serve(attend, store, { listen })` from `@acquiredintelligence/attend/server` to run the same thing inside your own process.
@@ -0,0 +1,73 @@
1
+ # Sinks
2
+
3
+ A sink is `{ deliver(decision, signal) }`. attend calls it at least once per decision, one decision at a time per scope, oldest first. Build it with `routes()`, which takes one handler per delivered route and fails to compile when one is missing.
4
+
5
+ ## The decision
6
+
7
+ | Field | Holds |
8
+ |---|---|
9
+ | `id` | The window id: a hash of the scope and the window's event ids, so it is unique across scopes. The same window redelivered has the same id. A merged window has a new one. |
10
+ | `scope` | The scope key |
11
+ | `route` | `interrupt`, `queue`, `note` or `ask`. A sink never sees `skip` or `wait`. |
12
+ | `probabilities` | One number per route, summing to 1. Routes that were not offered are 0. |
13
+ | `predicates` | The judge's answer to each predicate, by name |
14
+ | `window` | The settled window, with `events` oldest first |
15
+ | `at` | attend's clock when the judge answered |
16
+
17
+ ## Wiring template
18
+
19
+ Map each route to an operation in the host. The decision id makes each operation idempotent.
20
+
21
+ ```ts
22
+ import { createAttend, defaultNotice, routes, type DeadLetter, type Decision, type Event, type Judge, type Kind, type ScopeStore } from '@acquiredintelligence/attend';
23
+
24
+ export interface Host {
25
+ /** Deliver now, even mid-turn: steer or cancel the current turn. */
26
+ interrupt(key: string, text: string): Promise<void>;
27
+ /** Deliver as the next user turn once the current turn ends. */
28
+ enqueue(key: string, text: string): Promise<void>;
29
+ /** Add to the next turn's context without starting a turn. */
30
+ addContext(key: string, text: string): Promise<void>;
31
+ /** Show the user a prompt that calls the agent in if they accept. */
32
+ offer(key: string, text: string): Promise<void>;
33
+ saveDeadLetter(key: string, letter: DeadLetter): Promise<void>;
34
+ }
35
+
36
+ export function wire(host: Host, store: ScopeStore, judge: Judge, kind: (event: Event) => Kind) {
37
+ return createAttend({
38
+ store,
39
+ judge,
40
+ kind,
41
+ sink: routes({
42
+ interrupt: (d: Decision) => host.interrupt(d.id, defaultNotice(d)),
43
+ queue: (d: Decision) => host.enqueue(d.id, defaultNotice(d)),
44
+ note: (d: Decision) => host.addContext(d.id, defaultNotice(d)),
45
+ ask: (d: Decision) => host.offer(d.id, defaultNotice(d)),
46
+ }),
47
+ deadLetter: (letter) => host.saveDeadLetter(letter.id, letter),
48
+ });
49
+ }
50
+ ```
51
+
52
+ Each `Host` method records `key` and returns early when it has seen the key before: a unique index on an inbox table, a set of delivered keys in the agent's runtime, or an id on the message the host sends.
53
+
54
+ ## Routes in common hosts
55
+
56
+ | Host | `interrupt` | `queue` | `note` | `ask` |
57
+ |---|---|---|---|---|
58
+ | Chat host | Steer the running turn, or start one now | Append a user turn after the current one | Add a system note to the next turn | Show a "Ask the agent?" chip |
59
+ | Coding agent | Stop the current step and read the notice | Add to the task queue | Add to the session context | Post a suggestion the user can accept |
60
+ | Voice agent | Barge in at the next pause | Speak when the user finishes | Hold as context and stay silent | Play a short sound or show a hint |
61
+ | Server-side agent | Push to the agent's high-priority queue | Push to its normal queue | Write to its memory store | Notify the user's client |
62
+
63
+ ## Failures
64
+
65
+ - Throw to retry. attend retries through `retryBackoffMs`, then turns the decision into a dead letter.
66
+ - Throw `PermanentError` from `@acquiredintelligence/attend` to dead-letter at once, for a decision the host can never take.
67
+ - Each delivery holds a lease of `deliveryTimeoutMs`. attend aborts `signal` when it expires and counts it as a failed attempt. Stop when `signal` aborts.
68
+ - One stuck delivery holds back later decisions in the same scope, because delivery is in order. Dead letters go through their own lane, so a failing `deadLetter` never holds back decisions.
69
+ - Nothing is dropped. A scope whose sink stays down fills its decision lane to `maxOutbox`, and one whose dead-letter handler stays down fills its dead-letter lane to `maxDeadLetters`. Either cap makes `push` and a source's `emit` reject with `OutboxFullError` until the lane drains, and `attach` backs off and resubscribes on its own.
70
+
71
+ ## Dead letters
72
+
73
+ `deadLetter(letter, signal)` is optional. Without it, each dead letter becomes a final row in the decision log, readable with `attend.decisions({ kinds: ['deadLetter'] }, …)` and as `attention.deadLetter` over MCP. Pass a handler when the app has a place to put them. A `DeadLetter` has `stage` (`judge` or `sink`), `window`, `decision` (null when the judge gave up), `message`, `permanent` and `attempts`. attend retries the handler with capped backoff until it succeeds and never drops a letter. Write the letter to durable storage, keyed on its `id`, and alert on it from there.
@@ -0,0 +1,90 @@
1
+ # Stores
2
+
3
+ A store holds each scope's latest snapshot and the short tail of inputs committed since, a due index, and the decision log. A write names the version it read and lands only if that is still the stored version, so concurrent writers never overwrite each other. Every store passes the same conformance suite and crash suite.
4
+
5
+ ## Memory
6
+
7
+ ```ts
8
+ import { memoryStore } from '@acquiredintelligence/attend';
9
+
10
+ export const store = memoryStore();
11
+ ```
12
+
13
+ State lives in the process and is lost when it exits. Use it for tests, demos, browser apps and short-lived processes.
14
+
15
+ ## SQLite
16
+
17
+ ```ts
18
+ import { DatabaseSync } from 'node:sqlite';
19
+ import { sqliteStore } from '@acquiredintelligence/attend/sqlite';
20
+
21
+ export const store = sqliteStore(new DatabaseSync('attend.db'));
22
+ ```
23
+
24
+ - Takes a `node:sqlite` `DatabaseSync`, a `bun:sqlite` `Database`, or any object with the same `exec` and `prepare`, so importing `@acquiredintelligence/attend/sqlite` loads no driver. The conformance and crash suites pass under Bun 1.4 on `bun:sqlite`.
25
+ - Creates its table and index on construction. A second store over the same file uses what the first created.
26
+ - `prefix` (default `attend_`) sets the table names. Letters, digits and underscores only.
27
+ - Calls are synchronous and block the event loop for their duration.
28
+ - `sqliteStore({ file: 'attend.db', worker: true })` runs the database on a worker thread so commits never block the event loop.
29
+ - Several processes on one host can share the file. Keep the file off network filesystems.
30
+
31
+ ## Postgres
32
+
33
+ ```ts
34
+ import pg from 'pg';
35
+ import { migrate, postgresStore } from '@acquiredintelligence/attend/postgres';
36
+
37
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
38
+ await migrate(pool);
39
+ export const store = postgresStore(pool);
40
+ ```
41
+
42
+ - Takes any client with `query(text, values)`, such as a `pg` `Pool` or `Client`.
43
+ - Run `migrate()` once at deploy or startup, with a role that can create tables. It is idempotent and safe to run from several processes at once. The store itself runs no DDL and needs only `SELECT`, `INSERT`, `UPDATE` and `DELETE`.
44
+ - `prefix` (default `attend_`) must be lowercase letters, digits and underscores. To use another schema, set the connection's `search_path`.
45
+ - Safe for many replicas. Each runner sweeps for due scopes, and the version check lets one replica win each write. One statement commits a group of scopes together.
46
+
47
+ ## Redis
48
+
49
+ ```ts
50
+ import { createClient } from 'redis';
51
+ import { redisStore } from '@acquiredintelligence/attend/redis';
52
+
53
+ const client = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' });
54
+ await client.connect();
55
+ export const store = redisStore((args) => client.sendCommand(args));
56
+ ```
57
+
58
+ - Takes a function that sends one command. With ioredis, pass `([command, ...args]) => client.call(command, ...args)`.
59
+ - Needs Redis 6.2 or later. One script validates every version in a group, then changes the scopes, the due index and the decision stream together.
60
+ - Durable only as far as the server's persistence settings (AOF or RDB).
61
+ - On Redis Cluster, every key a group touches and the shared due key must hash to one slot. Use a prefix with a hash tag: `redisStore(send, { prefix: 'attend:{app}:' })`. That puts every scope on one shard.
62
+
63
+ ## Shared logs
64
+
65
+ Each store module also exports an `EventLog`: `memoryLog` from `@acquiredintelligence/attend`, `sqliteLog`, `postgresLog` and `redisLog`. Use one when producers live in other processes and you want a durable log in front of attend. Producers call `log.append(scope, events)`, and one process attaches the log:
66
+
67
+ ```ts
68
+ import { createAttend, logSource, memoryLog, memoryStore, routes } from '@acquiredintelligence/attend';
69
+
70
+ const log = memoryLog();
71
+ const attend = createAttend({
72
+ store: memoryStore(),
73
+ kind: () => 'closing',
74
+ judge: async () => ({ route: 'queue' }),
75
+ sink: routes({ interrupt: async () => {}, queue: async () => {}, note: async () => {}, ask: async () => {} }),
76
+ deadLetter: async () => {},
77
+ });
78
+
79
+ const controller = new AbortController();
80
+ await log.append('inbox:42', [{ id: 'm1', at: Date.now(), name: 'message.created', data: {}, text: 'Is the export done?' }]);
81
+ await Promise.all([attend.run({ signal: controller.signal }), attend.attach('inbox:42', logSource(log, 'inbox:42'), { signal: controller.signal })]);
82
+ ```
83
+
84
+ - `redisLog` waits for new entries with `XREAD BLOCK` only when you pass `blockingSend`, a send function on its own connection. Without it, `read` returns at once and `logSource` polls every `pollMs` (default 250).
85
+ - `postgresLog` polls every `pollMs` (default 100) while a read blocks. `sqliteLog` never blocks.
86
+ - The log may deliver an event more than once. The scope dedups by `event.id`.
87
+
88
+ ## Choosing between push and a log
89
+
90
+ `push` writes the scope's state directly from any process, with a version check. It needs no log, and it contends when many producers write one scope at a high rate: a writer that loses backs off a few milliseconds and tries again, and after eight losses `push` rejects with `ConflictError`. A log takes those writes off the scope, and one attached source moves them in.