@aeye/core 0.6.0 → 0.7.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,771 +1,790 @@
1
- # @aeye/core
2
-
3
- Core primitives for building AI agents, tools, and prompts with TypeScript. Provides a type-safe, composable framework for creating sophisticated AI applications with structured inputs/outputs, tool calling, and context management.
4
-
5
- ## Features
6
-
7
- - **🎯 Type-Safe Components** - Prompts, Tools, and Agents with full TypeScript support
8
- - **🔧 Tool Calling** - Native support for function/tool calling with schema validation
9
- - **📝 Template-Based Prompts** - Handlebars templates for dynamic prompt generation
10
- - **✅ Schema Validation** - Zod integration for structured inputs and outputs
11
- - **🌊 Streaming Support** - First-class streaming for real-time AI responses
12
- - **🔄 Composable Architecture** - Tools can use other tools, prompts can use tools, agents orchestrate everything
13
- - **📊 Context Management** - Type-safe context threading and automatic token window management
14
- - **🎛️ Flexible Execution** - Sequential, parallel, or immediate tool execution modes
15
-
16
- ## Installation
17
-
18
- ```bash
19
- npm install @aeye/core zod handlebars
20
- ```
21
-
22
- > **Note:** `@aeye/core` defines the primitives — you need an executor/streamer to run prompts. These are provided by `@aeye/ai` via `ai.buildCoreContext()`, or you can supply your own `execute` / `stream` functions.
23
-
24
- ## Core Concepts
25
-
26
- ### Components
27
-
28
- All AI primitives implement the `Component` interface:
29
-
30
- - **Prompt** - Generates AI responses with optional tool usage and structured outputs
31
- - **Tool** - Extends AI capabilities with custom functions and external integrations
32
- - **Agent** - Orchestrates complex workflows combining prompts and tools
33
-
34
- ### Context & Metadata
35
-
36
- - **Context (`TContext`)** - Application-specific data threaded through operations (user, db, etc.)
37
- - **Metadata (`TMetadata`)** - Execution settings for AI requests (model, temperature, etc.)
38
-
39
- ## Quick Start
40
-
41
- ### Basic Prompt
42
-
43
- ```typescript
44
- import { Prompt } from '@aeye/core';
45
- import z from 'zod';
46
-
47
- const summarizer = new Prompt({
48
- name: 'summarize',
49
- description: 'Summarizes text concisely',
50
- content: 'Summarize the following text:\n\n{{text}}',
51
-
52
- // Transform raw input into template variables
53
- input: (input: { text: string }) => ({ text: input.text }),
54
-
55
- // Define output schema (structured JSON)
56
- schema: z.object({
57
- summary: z.string().describe('A concise summary'),
58
- keyPoints: z.array(z.string()).describe('Main points'),
59
- }),
60
- });
61
-
62
- // Execute — ctx must provide execute or stream (from @aeye/ai or custom)
63
- const result = await summarizer.get(
64
- 'result',
65
- { text: 'Long article text...' },
66
- {
67
- execute: yourAIExecutor, // (request, ctx, metadata?, signal?) => Promise<Response>
68
- messages: [],
69
- }
70
- );
71
-
72
- console.log(result?.summary);
73
- console.log(result?.keyPoints);
74
- ```
75
-
76
- ### Creating Tools
77
-
78
- ```typescript
79
- import { Tool } from '@aeye/core';
80
- import z from 'zod';
81
-
82
- const getWeather = new Tool({
83
- name: 'getWeather',
84
- description: 'Get current weather for a location',
85
- instructions: 'Use this tool to get weather data for {{location}}.',
86
-
87
- schema: z.object({
88
- location: z.string().describe('City name or coordinates'),
89
- units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
90
- }),
91
-
92
- call: async (input, _refs, _ctx) => {
93
- const response = await fetch(
94
- `https://api.weather.com/v1/${encodeURIComponent(input.location)}`
95
- );
96
- const data = await response.json();
97
- return {
98
- temperature: data.temp,
99
- condition: data.condition,
100
- humidity: data.humidity,
101
- };
102
- },
103
- });
104
- ```
105
-
106
- ### Prompts with Tools
107
-
108
- ```typescript
109
- import { Prompt } from '@aeye/core';
110
- import z from 'zod';
111
-
112
- const travelAdvisor = new Prompt({
113
- name: 'travelAdvisor',
114
- description: 'Provides travel advice based on weather',
115
- content: `You are a travel advisor. Help plan a trip to {{destination}}.
116
-
117
- Use the getWeather tool to check current conditions, then provide:
118
- - What to pack
119
- - Recommended activities
120
- - Best times to visit`,
121
-
122
- input: (input: { destination: string }) => ({
123
- destination: input.destination,
124
- }),
125
-
126
- tools: [getWeather],
127
-
128
- schema: z.object({
129
- recommendations: z.array(z.string()),
130
- packingList: z.array(z.string()),
131
- weatherNotes: z.string(),
132
- }),
133
- });
134
-
135
- // The AI will automatically call getWeather if needed
136
- const advice = await travelAdvisor.get(
137
- 'result',
138
- { destination: 'Paris' },
139
- { execute: yourAIExecutor, messages: [] }
140
- );
141
-
142
- console.log(advice?.recommendations);
143
- ```
144
-
145
- ### Streaming Responses
146
-
147
- ```typescript
148
- // Stream text content only
149
- for await (const chunk of summarizer.get(
150
- 'streamContent',
151
- { text: 'Long text...' },
152
- { stream: yourAIStreamer, messages: [] }
153
- )) {
154
- process.stdout.write(chunk);
155
- }
156
-
157
- // Stream all events (including tool calls)
158
- for await (const event of summarizer.get(
159
- 'stream',
160
- { text: 'Long text...' },
161
- { stream: yourAIStreamer, messages: [] }
162
- )) {
163
- if (event.type === 'textPartial') {
164
- process.stdout.write(event.content);
165
- } else if (event.type === 'toolStart') {
166
- console.log('Tool started:', event.tool.name);
167
- } else if (event.type === 'toolOutput') {
168
- console.log('Tool result:', event.result);
169
- } else if (event.type === 'complete') {
170
- console.log('Final output:', event.output);
171
- }
172
- }
173
- ```
174
-
175
- ### Building Agents
176
-
177
- ```typescript
178
- import { Agent } from '@aeye/core';
179
- import z from 'zod';
180
-
181
- // Assume searchTool, summarizePrompt, and analyzePrompt are defined elsewhere
182
- const researchAgent = new Agent({
183
- name: 'researcher',
184
- description: 'Conducts research on a topic',
185
-
186
- refs: [searchTool, summarizePrompt, analyzePrompt] as const,
187
-
188
- call: async (
189
- input: { topic: string },
190
- [search, summarize, analyze],
191
- ctx
192
- ) => {
193
- // Step 1: Search for information
194
- const searchResults = await search.run({ query: input.topic, limit: 5 }, ctx);
195
-
196
- // Step 2: Summarize each result
197
- const summaries: string[] = [];
198
- for (const result of searchResults.items) {
199
- const summary = await summarize.get('result', { text: result.content }, ctx);
200
- summaries.push(summary?.summary ?? '');
201
- }
202
-
203
- // Step 3: Synthesize into a final analysis
204
- const analysis = await analyze.get(
205
- 'result',
206
- { topic: input.topic, sources: summaries },
207
- ctx
208
- );
209
-
210
- return analysis;
211
- },
212
- });
213
-
214
- const research = await researchAgent.run(
215
- { topic: 'Quantum Computing' },
216
- { execute: yourAIExecutor, messages: [] }
217
- );
218
- ```
219
-
220
- ## Prompt Modes
221
-
222
- The `get(mode, input?, ctx?)` method supports different execution modes:
223
-
224
- | Mode | Description | Returns |
225
- |------|-------------|---------|
226
- | `'result'` | Await the final structured output | `Promise<TOutput \| undefined>` |
227
- | `'tools'` | Await all tool call results | `Promise<PromptToolOutput[] \| undefined>` |
228
- | `'stream'` | Stream all events | `AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput \| undefined>` |
229
- | `'streamTools'` | Stream tool output events | `AsyncGenerator<PromptToolOutput<TTools>, TOutput \| undefined>` |
230
- | `'streamContent'` | Stream text content only | `AsyncGenerator<string, TOutput \| undefined>` |
231
-
232
- ```typescript
233
- // Get structured result
234
- const result = await prompt.get('result', input, ctx);
235
-
236
- // Get tool outputs only
237
- const tools = await prompt.get('tools', input, ctx);
238
-
239
- // Stream everything
240
- for await (const event of prompt.get('stream', input, ctx)) {
241
- // event.type is one of: 'request', 'textPartial', 'text', 'toolStart',
242
- // 'toolOutput', 'toolError', 'message', 'complete', 'usage', ...
243
- }
244
-
245
- // Stream text only
246
- for await (const text of prompt.get('streamContent', input, ctx)) {
247
- process.stdout.write(text);
248
- }
249
- ```
250
-
251
- ## Tool Execution Modes
252
-
253
- Control how tools are executed by the prompt:
254
-
255
- ```typescript
256
- const prompt = new Prompt({
257
- name: 'multi-tool',
258
- description: 'Uses multiple tools',
259
- content: 'Analyze this data using the available tools.',
260
- tools: [tool1, tool2, tool3],
261
-
262
- // 'sequential' | 'parallel' | 'immediate' (default: 'immediate')
263
- toolExecution: 'parallel',
264
-
265
- // Number of times to retry a failed tool call (default: 2)
266
- toolRetries: 2,
267
- // Max tool-calling iterations per request (default: 3)
268
- toolIterations: 3,
269
- // Max total successful tool calls before stopping (no strict limit by default)
270
- toolsMax: 5,
271
- });
272
- ```
273
-
274
- - **`sequential`** - Wait for each tool to complete before starting the next
275
- - **`parallel`** - Start all pending tools at once and wait for all to complete
276
- - **`immediate`** - Start each tool as soon as its call is received (default)
277
-
278
- ## Advanced Features
279
-
280
- ### Context-Aware Tools
281
-
282
- Tool schemas and applicability can depend on the current context:
283
-
284
- ```typescript
285
- interface MyContext {
286
- userRole: 'admin' | 'viewer';
287
- isAuthenticated: boolean;
288
- }
289
-
290
- const contextTool = new Tool({
291
- name: 'manageData',
292
- description: 'Manage data records',
293
- instructions: 'Use this tool to read or write data.',
294
-
295
- // Schema varies based on the caller's role
296
- schema: (ctx: Context<MyContext, {}>) => {
297
- if (ctx.userRole === 'admin') {
298
- return z.object({ action: z.enum(['read', 'write', 'delete']) });
299
- }
300
- return z.object({ action: z.enum(['read']) });
301
- },
302
-
303
- // Tool is only available to authenticated users
304
- applicable: (ctx: Context<MyContext, {}>) => ctx.isAuthenticated,
305
-
306
- call: async (input, _refs, ctx) => {
307
- return { action: input.action, executedBy: ctx.userRole };
308
- },
309
- });
310
- ```
311
-
312
- ### Custom Validation
313
-
314
- Add business-logic validation beyond Zod schema checks:
315
-
316
- ```typescript
317
- const placeOrder = new Tool({
318
- name: 'placeOrder',
319
- description: 'Place a product order',
320
-
321
- schema: z.object({
322
- itemId: z.string(),
323
- quantity: z.number().int().min(1),
324
- }),
325
-
326
- // Runs after schema parsing succeeds; throw to trigger re-prompting
327
- validate: async (input, ctx) => {
328
- const inventory = await checkInventory(input.itemId);
329
- if (inventory < input.quantity) {
330
- throw new Error(`Only ${inventory} units of ${input.itemId} are available.`);
331
- }
332
- },
333
-
334
- call: async (input, _refs, _ctx) => {
335
- return { orderId: crypto.randomUUID(), ...input };
336
- },
337
- });
338
- ```
339
-
340
- ### Event Tracking with `withEvents`
341
-
342
- ```typescript
343
- import { withEvents } from '@aeye/core';
344
-
345
- const runner = withEvents({
346
- onStatus: (instance) => {
347
- console.log(`${instance.component.name}: ${instance.status}`);
348
- if (instance.status === 'completed' && instance.completed && instance.running) {
349
- console.log(` Took ${instance.completed - instance.running}ms`);
350
- }
351
- },
352
-
353
- onPromptEvent: (_instance, event) => {
354
- if (event.type === 'usage') {
355
- console.log('Tokens used:', event.usage);
356
- }
357
- },
358
- });
359
-
360
- const result = await prompt.get(
361
- 'result',
362
- { text: 'Hello' },
363
- { execute: yourAIExecutor, messages: [], runner }
364
- );
365
- ```
366
-
367
- ### Token Management
368
-
369
- The prompt automatically trims old messages when the context window is full:
370
-
371
- ```typescript
372
- const result = await prompt.get(
373
- 'result',
374
- { text: 'Query' },
375
- {
376
- execute: yourAIExecutor,
377
- messages: conversationHistory, // Will be trimmed if needed
378
-
379
- // Reserve this many tokens for the model's response
380
- maxOutputTokens: 2048,
381
-
382
- // Custom per-message token estimator
383
- estimateUsage: (message) => ({
384
- text: { input: Math.ceil((message.content as string).length / 4) }
385
- }),
386
- }
387
- );
388
- ```
389
-
390
- ### Dynamic Reconfiguration
391
-
392
- Use `reconfig` to adapt the prompt based on runtime statistics:
393
-
394
- ```typescript
395
- const adaptivePrompt = new Prompt({
396
- name: 'adaptive',
397
- description: 'Adapts based on runtime stats',
398
- content: 'Solve: {{problem}}',
399
- schema: z.object({ solution: z.string() }),
400
-
401
- reconfig: (stats, _ctx) => {
402
- // If many tool errors occurred, switch to one-at-a-time mode
403
- if (stats.toolCallErrors > 3) {
404
- return { config: { toolsOneAtATime: true } };
405
- }
406
- return {};
407
- },
408
- });
409
- ```
410
-
411
- ## Schema Fallbacks
412
-
413
- A provider that rejects a schema throws `SchemaRejectedError` with a `target` naming the output or the tool. The prompt then steps down its `schemaFallbacks`, by default `SCHEMA_FALLBACKS`: the rejected schema goes non-strict, then the output moves to JSON mode with its schema in the prompt (`schemaDelivery: 'json'`), then to plain text with its schema in the prompt (`'prompt'`). Steps stay on the request for the rest of the run, and each emits a `schemaFallback` event.
414
-
415
- ```typescript
416
- import { Prompt, SCHEMA_FALLBACKS } from '@aeye/core';
417
-
418
- const prompt = new Prompt({
419
- name: 'extract',
420
- description: 'Extracts fields',
421
- content: 'Extract the fields from: {{text}}',
422
- schema,
423
- schemaFallbacks: SCHEMA_FALLBACKS.filter(step => step.name !== 'json'),
424
- });
425
- ```
426
-
427
- Providers use `schemaRejection(request, error)` to classify a bad request and `deliverSchema(request)` to send a `json` or `prompt` schema without changing the prompt's request.
428
-
429
- ## Error Kinds
430
-
431
- Every prompt failure has `kind: 'model' | 'tool'`. `ToolInputError` (an unknown tool or bad arguments) and `OutputValidationError` (output that isn't JSON or fails the schema or `validate`) are model mistakes, fed back to the model for another try. `ToolExecutionError` is a tool's own throw. They appear on the `toolError` event's `failure`, on tool result messages' `failure`, and on the `textReset` event's `error`. A prompt that gives up throws a `PromptError` whose `cause` is the last failure.
432
-
433
- ## Review
434
-
435
- `review` judges the parsed, validated output before it's returned. Feedback becomes a user message and the model answers again, up to `reviewRetries` times; after that the prompt throws a `PromptError` caused by a `ReviewRejectedError` with the last output.
436
-
437
- ```typescript
438
- const answer = new Prompt({
439
- name: 'answer',
440
- description: 'Answers briefly',
441
- content: 'Answer in under 20 words: {{question}}',
442
- input: (input: { question: string }) => input,
443
- review: (output) => output.split(' ').length < 20 ? { pass: true } : { feedback: 'Use fewer than 20 words.' },
444
- });
445
- ```
446
-
447
- ## Model Capabilities
448
-
449
- @aeye uses a capability system for automatic model selection. Models advertise their capabilities and the library selects the best match for each request:
450
-
451
- | Capability | Description |
452
- |------------|-------------|
453
- | `chat` | Basic text completion |
454
- | `streaming` | Real-time response streaming |
455
- | `image` | Image generation |
456
- | `vision` | Image understanding |
457
- | `audio` | Text-to-speech |
458
- | `hearing` | Speech-to-text |
459
- | `embedding` | Text embeddings |
460
- | `rerank` | Ordering documents by relevance |
461
- | `decision` | Typed answers (probabilities, choices, scores) from decision models |
462
- | `tools` | Function/tool calling |
463
- | `json` | JSON output mode |
464
- | `structured` | Structured outputs with schemas |
465
- | `toolsStrict` | Strict-mode tool input schemas (auto-derived; see [Strict Mode](#strict-mode)) |
466
- | `reasoning` | Extended chain-of-thought reasoning |
467
- | `zdr` | Zero data retention |
468
-
469
- ## Strict Mode
470
-
471
- Each LLM provider that supports strict mode does so with its own JSON Schema dialect. `@aeye/core` reconciles them under a single `FormatDescriptor` so a Zod schema authored once works against any of them.
472
-
473
- ### `strict` on Tool / Prompt
474
-
475
- `Tool.strict` and `Prompt.strict` are `boolean | number`:
476
-
477
- | Value | Meaning |
478
- |---|---|
479
- | `true` | **Require** strict. `@aeye/ai` filters selection down to models that declare strict support; the request fails if none qualify. |
480
- | `false` | **Force** lenient. Standard JSON Schema; no `strict: true` on the wire. |
481
- | `number > 0` (default `1`) | **Prefer** strict, accept fallback. Higher numbers win when there are more strict-requesting items than the chosen model's per-request budget allows. |
482
- | omitted | Treated as `1`. |
483
-
484
- ```typescript
485
- new Tool({ name: 'must', /* default 1 */ schema: z.object({...}) }); // best-effort
486
- new Tool({ name: 'critical', strict: true, schema: z.object({...}) }); // hard require
487
- new Tool({ name: 'meh', strict: false, schema: z.object({...}) }); // never strict
488
- new Tool({ name: 'top', strict: 100, schema: z.object({...}) }); // high priority
489
- ```
490
-
491
- Default `1` keeps "it just works" against unannotated models — strict where the chosen model supports it, lenient elsewhere. Set `true` only when strict is non-negotiable.
492
-
493
- ### Native format descriptors
494
-
495
- Seven descriptors ship out of the box:
496
-
497
- | Descriptor | Family / Strict | Notes |
498
- |---|---|---|
499
- | `OPENAI_STRICT` | openai / strict | Records → array-of-pairs, tuples → numeric-key objects, `optional → T \| null`, closed objects, restricted format whitelist. |
500
- | `ANTHROPIC_STRICT` | anthropic / strict | Closed objects, no recursion, no length / numeric range constraints, per-request slot budgets (20 strict tools / 24 optional params / 16 union types). |
501
- | `GOOGLE_STRICT` | google / strict | `prefixItems`, `$ref: '#'` recursion, `propertyOrdering` emitted, restricted format whitelist. |
502
- | `LENIENT` | lenient / non-strict | No rewrites, all features pass through. Default for unannotated models. |
503
- | `OPENAI_NON_STRICT`, `ANTHROPIC_NON_STRICT`, `GOOGLE_NON_STRICT` | family / non-strict | Aliased to `LENIENT` but tagged with the family for diagnostics. |
504
-
505
- ```typescript
506
- import { OPENAI_STRICT, ANTHROPIC_STRICT, getDescriptor, toJSONSchema } from '@aeye/core';
507
-
508
- const schema = z.object({
509
- name: z.string(),
510
- tags: z.record(z.string(), z.string()),
511
- });
512
-
513
- // Same Zod, different wire shapes:
514
- toJSONSchema(schema, OPENAI_STRICT); // tags → array of {key, value}
515
- toJSONSchema(schema, ANTHROPIC_STRICT); // tags → array of {key, value} (open records unsupported)
516
- toJSONSchema(schema, getDescriptor('google', true)); // tags → standard additionalProperties
517
-
518
- // Backward-compat overloads still work:
519
- toJSONSchema(schema, true); // === OPENAI_STRICT
520
- toJSONSchema(schema, false); // === LENIENT
521
- ```
522
-
523
- ### Registering a custom descriptor
524
-
525
- You can ship your own descriptor for a provider or dialect that isn't supported natively. Define it as a `FormatDescriptor` value and register it with `registerDescriptor(descriptor)`:
526
-
527
- ```typescript
528
- import { registerDescriptor, type FormatDescriptor, OPENAI_STRICT } from '@aeye/core';
529
-
530
- const MY_PROVIDER_STRICT: FormatDescriptor = {
531
- ...OPENAI_STRICT, // start from a known good baseline
532
- id: 'my-provider-strict',
533
- family: 'openai', // or 'anthropic' | 'google' | 'lenient'
534
- // Override only the fields that differ from the baseline:
535
- recordEncoding: 'open-record', // your provider supports additionalProperties: <schema>
536
- tupleEncoding: 'prefix-items',
537
- supportsRecursion: true,
538
- };
539
-
540
- registerDescriptor(MY_PROVIDER_STRICT);
541
- ```
542
-
543
- Once registered:
544
-
545
- - Provider code can pass the descriptor to `toJSONSchema(schema, MY_PROVIDER_STRICT)` and `strictify(schema, MY_PROVIDER_STRICT)`.
546
- - The Prompt's validation roundtrip (which looks up descriptors by id from the request) will resolve `'my-provider-strict'` correctly.
547
- - A custom `Provider` implementation can return this descriptor from its `getStrictFormat`-equivalent helper.
548
-
549
- The descriptor's full field list is exported as the `FormatDescriptor` interface — every flag is documented inline. Common adjustments:
550
-
551
- | Field | Controls |
552
- |---|---|
553
- | `objectAllFieldsRequired` | Whether every property must appear in `required[]`. |
554
- | `objectClosedByDefault` | Whether every object emits `additionalProperties: false`. |
555
- | `recordEncoding` | `'array-of-pairs'` vs `'open-record'`. |
556
- | `tupleEncoding` | `'object-numeric-keys'` vs `'prefix-items'` vs `'items-union'`. |
557
- | `allowAllOf` / `allowAnyOf` / `allowOneOf` | JSON-Schema combinator support. |
558
- | `supportedStringFormats` | `'all'` or a `Set<string>` whitelist (`'date-time'`, `'email'`, etc.). |
559
- | `optionalAsNullable` | Whether `optional` fields are emitted as `T \| null` (OpenAI strict) vs omitted from `required[]`. |
560
- | `supportsRecursion` | Whether `$ref` self-reference works under this dialect. |
561
- | `maxStrictTools` / `maxStrictOptionalParams` / `maxStrictUnionTypes` | Per-request budget caps; `undefined` means no documented limit. |
562
- | `schemaSizeLimits` | Per-SCHEMA size ceilings (properties, nesting, total string characters, enum values); `undefined` means no documented limit. An over-size schema degrades on its own, even when it asked for `strict: true`. |
563
-
564
- You don't need to set every field — spread an existing descriptor and override deltas. Tested via `analyzeSchema` + the schema test suite; see the strict-mode guide in `@aeye/docs` for end-to-end examples.
565
-
566
- ## API Reference
567
-
568
- ### Prompt
569
-
570
- ```typescript
571
- class Prompt<TContext, TMetadata, TName extends string, TInput extends object, TOutput extends object | string, TTools extends Tuple<ToolCompatible<TContext, TMetadata>>, TDecoded extends unknown = TOutput>
572
- ```
573
-
574
- `TDecoded` is the type a custom `parse` produces from the model's structured output (e.g. a built AST/class instance); it types `validate` and the decoded output. It defaults to `TOutput` (the wire type) when no custom `parse` is supplied — `schema` always stays the wire type.
575
-
576
- **Constructor Options (`PromptInput`):**
577
- - `name: string` - Unique identifier
578
- - `description: string` - Purpose description
579
- - `content: string` - Handlebars template for the system/user message
580
- - `input?: (input: TInput, ctx: Context<TContext, TMetadata>) => Record<string, any>` - Maps raw input to template variables
581
- - `schema?: ZodType<TOutput> | ((input: TInput | undefined, ctx: Context<TContext, TMetadata>) => ZodType<TOutput> | false)` - Output schema; `false` disables structured output
582
- - `strict?: boolean | number` - Strict tool inputs: `true` requires, `false` disables, a number is a preference priority (default: `1`)
583
- - `format?: ToolTextFormat` - Take free text (`{ type: 'text' }`) or grammar-constrained text (`{ type: 'grammar', syntax: 'lark' | 'regex', definition }`) as `input` instead of JSON; use `TEXT_TOOL_SCHEMA` as the schema
584
- - `defer?: boolean` - Load only when the model searches for it (OpenAI tool search)
585
- - `async?: boolean` - The model keeps working while the tool runs (OpenAI Responses)
586
- - `config?: Partial<Request> | ((input: TInput | undefined, ctx: Context<TContext, TMetadata>) => Partial<Request> | false)` - AI request overrides
587
- - `tools?: TTools` - Tools the prompt may call
588
- - `toolExecution?: 'sequential' | 'parallel' | 'immediate'` - Tool execution mode
589
- - `toolRetries?: number` - Retry count for failed tools (default: 2)
590
- - `toolIterations?: number` - Max tool-calling iterations (default: 3)
591
- - `toolsMax?: number` - Max successful tool calls
592
- - `outputRetries?: number` - Retry count for invalid structured outputs
593
- - `schemaDelivery?: 'auto' | 'structured' | 'json' | 'prompt'` - How the output schema reaches the model (default: `'auto'`)
594
- - `schemaFallbacks?: readonly SchemaFallback[]` - Steps taken when a provider rejects a schema (default: the context's, then `SCHEMA_FALLBACKS`)
595
- - `metadata?: TMetadata` - Static metadata for this prompt
596
- - `validate?: (output: TOutput, ctx: Context<TContext, TMetadata>) => void | Promise<void>` - Post-parse validation
597
- - `review?: (output: TDecoded, context: PromptReviewContext) => PromptReview | Promise<PromptReview>` - Review the valid output: `{ pass: true }` or `{ feedback }` for another round
598
- - `reviewRetries?: number` - Rounds the model gets to answer review feedback (default: 1)
599
- - `applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean>` - Availability check
600
-
601
- **Methods:**
602
- - `get(mode: PromptGetType, input?: TInput, ctx?: Context<TContext, TMetadata>): PromptGet<...>` - Execute and return output in the specified mode
603
- - `run(input?: TInput, ctx?: Context<TContext, TMetadata>): AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput | undefined>` - Full streaming generator
604
- - `applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean>` - Check if prompt is usable
605
-
606
- ### Tool
607
-
608
- ```typescript
609
- class Tool<TContext, TMetadata, TName extends string, TParams extends object, TOutput, TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>>, TDecoded extends unknown = TParams>
610
- ```
611
-
612
- `TDecoded` is the type a custom `parse` produces from the raw wire args (e.g. a built class instance); it types `call`, `validate`, and `metadataFn`. It defaults to `TParams` (the wire type) when no custom `parse` is supplied — `schema` always stays the wire type.
613
-
614
- **Constructor Options (`ToolInput`):**
615
- - `name: string` - Unique identifier (shown to the AI model)
616
- - `description: string` - What the tool does (shown to the AI model)
617
- - `instructions?: string` - Handlebars template for usage instructions
618
- - `schema: ZodType<TParams> | ((ctx: Context<TContext, TMetadata>) => ZodType<TParams> | undefined)` - Input parameter schema
619
- - `strict?: boolean` - Enforce strict Zod schema (default: `true`)
620
- - `refs?: TRefs` - Referenced components
621
- - `call: (input: TParams, refs: TRefs, ctx: Context<TContext, TMetadata>) => TOutput` - Implementation
622
- - `validate?: (input: TParams, ctx: Context<TContext, TMetadata>) => void | Promise<void>` - Post-parse validation
623
- - `applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean>` - Availability check
624
-
625
- **Methods:**
626
- - `run(input?: TParams, ctx?: Context<TContext, TMetadata>): TOutput` - Execute the tool directly
627
- - `compile(ctx: Context<TContext, TMetadata>): Promise<readonly [string, ToolDefinition] | undefined>` - Generate AI tool definition
628
- - `applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean>` - Check if tool is usable
629
-
630
- ### Agent
631
-
632
- ```typescript
633
- class Agent<TContext, TMetadata, TName extends string, TInput extends object, TOutput, TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>>>
634
- ```
635
-
636
- **Constructor Options (`AgentInput`):**
637
- - `name: string` - Unique identifier
638
- - `description: string` - Purpose description
639
- - `refs: TRefs` - Components this agent uses
640
- - `call: (input: TInput, refs: TRefs, ctx: Context<TContext, TMetadata>) => TOutput` - Implementation
641
- - `applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean>` - Availability check
642
-
643
- **Methods:**
644
- - `run(input?: TInput, ctx?: Context<TContext, TMetadata>): TOutput` - Execute the agent
645
- - `applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean>` - Check if agent is usable
646
-
647
- ## Context Structure
648
-
649
- The `Context<TContext, TMetadata>` type combines your custom context with the core execution context:
650
-
651
- ```typescript
652
- // Core fields available in every context
653
- interface CoreContextFields {
654
- // AI executor for non-streaming requests
655
- execute?: (request: Request, ctx: TContext, metadata?: TMetadata, signal?: AbortSignal) => Promise<Response>;
656
- // AI streamer for streaming requests
657
- stream?: (request: Request, ctx: TContext, metadata?: TMetadata, signal?: AbortSignal) => AsyncGenerator<Chunk, Response>;
658
-
659
- // Conversation history passed to the model
660
- messages?: Message[];
661
-
662
- // Reserve N tokens for the model's output (used for context trimming)
663
- maxOutputTokens?: number;
664
- // Override context window size (used for context trimming)
665
- contextWindow?: number;
666
-
667
- // Custom per-message usage estimator
668
- estimateUsage?: (message: Message) => Usage | undefined;
669
-
670
- // Number of output-format retries (default: 2)
671
- outputRetries?: number;
672
- // Number of context-trimming retries (default: 1)
673
- forgetRetries?: number;
674
- // Steps a prompt takes when a provider rejects a schema (default: SCHEMA_FALLBACKS)
675
- schemaFallbacks?: readonly SchemaFallback[];
676
-
677
- // Abort signal for cancellation
678
- signal?: AbortSignal;
679
- // Event runner for tracking component lifecycle
680
- runner?: Events;
681
- }
682
- ```
683
-
684
- ## Request Configuration
685
-
686
- ```typescript
687
- interface Request {
688
- messages: Message[];
689
- temperature?: number; // 0.0–2.0
690
- topP?: number; // 0.0–1.0
691
- maxTokens?: number;
692
- stop?: string | string[];
693
- tools?: ToolDefinition[];
694
- toolChoice?: 'auto' | 'required' | 'none' | { tool: string } | { allowed: string[]; required?: boolean };
695
- responseFormat?: 'text' | 'json' | { type: ZodType; strict: boolean | number };
696
- reason?: { effort?: ReasoningEffort; maxTokens?: number; summary?: 'auto' | 'concise' | 'detailed'; exclude?: boolean };
697
- frequencyPenalty?: number;
698
- presencePenalty?: number;
699
- seed?: number;
700
- // ... more options
701
- }
702
- ```
703
-
704
- ## Best Practices
705
-
706
- 1. **Type Safety** - Use TypeScript generics for context and metadata
707
- 2. **Schema Validation** - Use Zod for robust input/output validation
708
- 3. **Token Management** - Supply `estimateUsage` for accurate context trimming
709
- 4. **Tool Organization** - Group related tools and use agents to orchestrate them
710
- 5. **Testing** - Unit test tools and prompts independently
711
- 6. **Context Minimization** - Pass only necessary data in context
712
- 7. **Streaming** - Use streaming for better UX with long responses
713
- 8. **Validation** - Use `validate` hooks for business-logic checks after schema parsing
714
-
715
- ## Examples
716
-
717
- See the `src/__tests__` directory for comprehensive examples:
718
-
719
- - **prompt-core-features.test.ts** - Basic prompt usage
720
- - **prompt-streaming-tool-events.test.ts** - Streaming and events
721
- - **tool.test.ts** - Tool creation and usage
722
- - **agent.test.ts** - Agent orchestration
723
- - **context-propagation.test.ts** - Context handling
724
-
725
- ## TypeScript Support
726
-
727
- Full type inference across component hierarchies:
728
-
729
- ```typescript
730
- import { Prompt, Tool } from '@aeye/core';
731
- import z from 'zod';
732
-
733
- // Output type is inferred from the schema
734
- const prompt = new Prompt({
735
- name: 'example',
736
- description: 'Example prompt',
737
- content: 'Say hello to {{name}}.',
738
- input: (input: { name: string }) => ({ name: input.name }),
739
- schema: z.object({ greeting: z.string() }),
740
- });
741
-
742
- // result is typed as { greeting: string } | undefined
743
- const result = await prompt.get('result', { name: 'World' }, ctx);
744
- console.log(result?.greeting);
745
-
746
- // Tool call parameters are type-safe
747
- const mathTool = new Tool({
748
- name: 'add',
749
- description: 'Adds two numbers',
750
- schema: z.object({ a: z.number(), b: z.number() }),
751
- call: (input, _refs, _ctx) => {
752
- // input is typed as { a: number; b: number }
753
- return input.a + input.b;
754
- },
755
- });
756
- ```
757
-
758
- ## Contributing
759
-
760
- Contributions are welcome! See the main [@aeye repository](https://github.com/ClickerMonkey/aeye) for contribution guidelines.
761
-
762
- ## License
763
-
764
- GPL-3.0 © [ClickerMonkey](https://github.com/ClickerMonkey)
765
-
766
- ## Links
767
-
768
- - [Root README](../../README.md)
769
- - [@aeye/ai README](../ai/README.md)
770
- - [GitHub Repository](https://github.com/ClickerMonkey/aeye)
771
- - [Issue Tracker](https://github.com/ClickerMonkey/aeye/issues)
1
+ # @aeye/core
2
+
3
+ Core primitives for building AI agents, tools, and prompts with TypeScript. Provides a type-safe, composable framework for creating sophisticated AI applications with structured inputs/outputs, tool calling, and context management.
4
+
5
+ ## Features
6
+
7
+ - **🎯 Type-Safe Components** - Prompts, Tools, and Agents with full TypeScript support
8
+ - **🔧 Tool Calling** - Native support for function/tool calling with schema validation
9
+ - **📝 Template-Based Prompts** - Handlebars templates for dynamic prompt generation
10
+ - **✅ Schema Validation** - Zod integration for structured inputs and outputs
11
+ - **🌊 Streaming Support** - First-class streaming for real-time AI responses
12
+ - **🔄 Composable Architecture** - Tools can use other tools, prompts can use tools, agents orchestrate everything
13
+ - **📊 Context Management** - Type-safe context threading and automatic token window management
14
+ - **🎛️ Flexible Execution** - Sequential, parallel, or immediate tool execution modes
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @aeye/core zod handlebars
20
+ ```
21
+
22
+ > **Note:** `@aeye/core` defines the primitives — you need an executor/streamer to run prompts. These are provided by `@aeye/ai` via `ai.buildCoreContext()`, or you can supply your own `execute` / `stream` functions.
23
+
24
+ ## Core Concepts
25
+
26
+ ### Components
27
+
28
+ All AI primitives implement the `Component` interface:
29
+
30
+ - **Prompt** - Generates AI responses with optional tool usage and structured outputs
31
+ - **Tool** - Extends AI capabilities with custom functions and external integrations
32
+ - **Agent** - Orchestrates complex workflows combining prompts and tools
33
+
34
+ ### Context & Metadata
35
+
36
+ - **Context (`TContext`)** - Application-specific data threaded through operations (user, db, etc.)
37
+ - **Metadata (`TMetadata`)** - Execution settings for AI requests (model, temperature, etc.)
38
+
39
+ ## Quick Start
40
+
41
+ ### Basic Prompt
42
+
43
+ ```typescript
44
+ import { Prompt } from '@aeye/core';
45
+ import z from 'zod';
46
+
47
+ const summarizer = new Prompt({
48
+ name: 'summarize',
49
+ description: 'Summarizes text concisely',
50
+ content: 'Summarize the following text:\n\n{{text}}',
51
+
52
+ // Transform raw input into template variables
53
+ input: (input: { text: string }) => ({ text: input.text }),
54
+
55
+ // Define output schema (structured JSON)
56
+ schema: z.object({
57
+ summary: z.string().describe('A concise summary'),
58
+ keyPoints: z.array(z.string()).describe('Main points'),
59
+ }),
60
+ });
61
+
62
+ // Execute — ctx must provide execute or stream (from @aeye/ai or custom)
63
+ const result = await summarizer.get(
64
+ 'result',
65
+ { text: 'Long article text...' },
66
+ {
67
+ execute: yourAIExecutor, // (request, ctx, metadata?, signal?) => Promise<Response>
68
+ messages: [],
69
+ }
70
+ );
71
+
72
+ console.log(result?.summary);
73
+ console.log(result?.keyPoints);
74
+ ```
75
+
76
+ ### Creating Tools
77
+
78
+ ```typescript
79
+ import { Tool } from '@aeye/core';
80
+ import z from 'zod';
81
+
82
+ const getWeather = new Tool({
83
+ name: 'getWeather',
84
+ description: 'Get current weather for a location',
85
+ instructions: 'Use this tool to get weather data for {{location}}.',
86
+
87
+ schema: z.object({
88
+ location: z.string().describe('City name or coordinates'),
89
+ units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
90
+ }),
91
+
92
+ call: async (input, _refs, _ctx) => {
93
+ const response = await fetch(
94
+ `https://api.weather.com/v1/${encodeURIComponent(input.location)}`
95
+ );
96
+ const data = await response.json();
97
+ return {
98
+ temperature: data.temp,
99
+ condition: data.condition,
100
+ humidity: data.humidity,
101
+ };
102
+ },
103
+ });
104
+ ```
105
+
106
+ ### Prompts with Tools
107
+
108
+ ```typescript
109
+ import { Prompt } from '@aeye/core';
110
+ import z from 'zod';
111
+
112
+ const travelAdvisor = new Prompt({
113
+ name: 'travelAdvisor',
114
+ description: 'Provides travel advice based on weather',
115
+ content: `You are a travel advisor. Help plan a trip to {{destination}}.
116
+
117
+ Use the getWeather tool to check current conditions, then provide:
118
+ - What to pack
119
+ - Recommended activities
120
+ - Best times to visit`,
121
+
122
+ input: (input: { destination: string }) => ({
123
+ destination: input.destination,
124
+ }),
125
+
126
+ tools: [getWeather],
127
+
128
+ schema: z.object({
129
+ recommendations: z.array(z.string()),
130
+ packingList: z.array(z.string()),
131
+ weatherNotes: z.string(),
132
+ }),
133
+ });
134
+
135
+ // The AI will automatically call getWeather if needed
136
+ const advice = await travelAdvisor.get(
137
+ 'result',
138
+ { destination: 'Paris' },
139
+ { execute: yourAIExecutor, messages: [] }
140
+ );
141
+
142
+ console.log(advice?.recommendations);
143
+ ```
144
+
145
+ ### Streaming Responses
146
+
147
+ ```typescript
148
+ // Stream text content only
149
+ for await (const chunk of summarizer.get(
150
+ 'streamContent',
151
+ { text: 'Long text...' },
152
+ { stream: yourAIStreamer, messages: [] }
153
+ )) {
154
+ process.stdout.write(chunk);
155
+ }
156
+
157
+ // Stream all events (including tool calls)
158
+ for await (const event of summarizer.get(
159
+ 'stream',
160
+ { text: 'Long text...' },
161
+ { stream: yourAIStreamer, messages: [] }
162
+ )) {
163
+ if (event.type === 'textPartial') {
164
+ process.stdout.write(event.content);
165
+ } else if (event.type === 'toolStart') {
166
+ console.log('Tool started:', event.tool.name);
167
+ } else if (event.type === 'toolOutput') {
168
+ console.log('Tool result:', event.result);
169
+ } else if (event.type === 'complete') {
170
+ console.log('Final output:', event.output);
171
+ }
172
+ }
173
+ ```
174
+
175
+ ### Building Agents
176
+
177
+ ```typescript
178
+ import { Agent } from '@aeye/core';
179
+ import z from 'zod';
180
+
181
+ // Assume searchTool, summarizePrompt, and analyzePrompt are defined elsewhere
182
+ const researchAgent = new Agent({
183
+ name: 'researcher',
184
+ description: 'Conducts research on a topic',
185
+
186
+ refs: [searchTool, summarizePrompt, analyzePrompt] as const,
187
+
188
+ call: async (
189
+ input: { topic: string },
190
+ [search, summarize, analyze],
191
+ ctx
192
+ ) => {
193
+ // Step 1: Search for information
194
+ const searchResults = await search.run({ query: input.topic, limit: 5 }, ctx);
195
+
196
+ // Step 2: Summarize each result
197
+ const summaries: string[] = [];
198
+ for (const result of searchResults.items) {
199
+ const summary = await summarize.get('result', { text: result.content }, ctx);
200
+ summaries.push(summary?.summary ?? '');
201
+ }
202
+
203
+ // Step 3: Synthesize into a final analysis
204
+ const analysis = await analyze.get(
205
+ 'result',
206
+ { topic: input.topic, sources: summaries },
207
+ ctx
208
+ );
209
+
210
+ return analysis;
211
+ },
212
+ });
213
+
214
+ const research = await researchAgent.run(
215
+ { topic: 'Quantum Computing' },
216
+ { execute: yourAIExecutor, messages: [] }
217
+ );
218
+ ```
219
+
220
+ ## Prompt Modes
221
+
222
+ The `get(mode, input?, ctx?)` method supports different execution modes:
223
+
224
+ | Mode | Description | Returns |
225
+ |------|-------------|---------|
226
+ | `'result'` | Await the final structured output | `Promise<TOutput \| undefined>` |
227
+ | `'tools'` | Await all tool call results | `Promise<PromptToolOutput[] \| undefined>` |
228
+ | `'stream'` | Stream all events | `AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput \| undefined>` |
229
+ | `'streamTools'` | Stream tool output events | `AsyncGenerator<PromptToolOutput<TTools>, TOutput \| undefined>` |
230
+ | `'streamContent'` | Stream text content only | `AsyncGenerator<string, TOutput \| undefined>` |
231
+
232
+ ```typescript
233
+ // Get structured result
234
+ const result = await prompt.get('result', input, ctx);
235
+
236
+ // Get tool outputs only
237
+ const tools = await prompt.get('tools', input, ctx);
238
+
239
+ // Stream everything
240
+ for await (const event of prompt.get('stream', input, ctx)) {
241
+ // event.type is one of: 'request', 'textPartial', 'text', 'toolStart',
242
+ // 'toolOutput', 'toolError', 'message', 'complete', 'usage', ...
243
+ }
244
+
245
+ // Stream text only
246
+ for await (const text of prompt.get('streamContent', input, ctx)) {
247
+ process.stdout.write(text);
248
+ }
249
+ ```
250
+
251
+ ## Tool Execution Modes
252
+
253
+ Control how tools are executed by the prompt:
254
+
255
+ ```typescript
256
+ const prompt = new Prompt({
257
+ name: 'multi-tool',
258
+ description: 'Uses multiple tools',
259
+ content: 'Analyze this data using the available tools.',
260
+ tools: [tool1, tool2, tool3],
261
+
262
+ // 'sequential' | 'parallel' | 'immediate' (default: 'immediate')
263
+ toolExecution: 'parallel',
264
+
265
+ // Number of times to retry a failed tool call (default: 2)
266
+ toolRetries: 2,
267
+ // Max tool-calling iterations per request (default: 3)
268
+ toolIterations: 3,
269
+ // Max total successful tool calls before stopping (no strict limit by default)
270
+ toolsMax: 5,
271
+ });
272
+ ```
273
+
274
+ - **`sequential`** - Wait for each tool to complete before starting the next
275
+ - **`parallel`** - Start all pending tools at once and wait for all to complete
276
+ - **`immediate`** - Start each tool as soon as its call is received (default)
277
+
278
+ ## Advanced Features
279
+
280
+ ### Context-Aware Tools
281
+
282
+ Tool schemas and applicability can depend on the current context:
283
+
284
+ ```typescript
285
+ interface MyContext {
286
+ userRole: 'admin' | 'viewer';
287
+ isAuthenticated: boolean;
288
+ }
289
+
290
+ const contextTool = new Tool({
291
+ name: 'manageData',
292
+ description: 'Manage data records',
293
+ instructions: 'Use this tool to read or write data.',
294
+
295
+ // Schema varies based on the caller's role
296
+ schema: (ctx: Context<MyContext, {}>) => {
297
+ if (ctx.userRole === 'admin') {
298
+ return z.object({ action: z.enum(['read', 'write', 'delete']) });
299
+ }
300
+ return z.object({ action: z.enum(['read']) });
301
+ },
302
+
303
+ // Tool is only available to authenticated users
304
+ applicable: (ctx: Context<MyContext, {}>) => ctx.isAuthenticated,
305
+
306
+ call: async (input, _refs, ctx) => {
307
+ return { action: input.action, executedBy: ctx.userRole };
308
+ },
309
+ });
310
+ ```
311
+
312
+ ### Custom Validation
313
+
314
+ Add business-logic validation beyond Zod schema checks:
315
+
316
+ ```typescript
317
+ const placeOrder = new Tool({
318
+ name: 'placeOrder',
319
+ description: 'Place a product order',
320
+
321
+ schema: z.object({
322
+ itemId: z.string(),
323
+ quantity: z.number().int().min(1),
324
+ }),
325
+
326
+ // Runs after schema parsing succeeds; throw to trigger re-prompting
327
+ validate: async (input, ctx) => {
328
+ const inventory = await checkInventory(input.itemId);
329
+ if (inventory < input.quantity) {
330
+ throw new Error(`Only ${inventory} units of ${input.itemId} are available.`);
331
+ }
332
+ },
333
+
334
+ call: async (input, _refs, _ctx) => {
335
+ return { orderId: crypto.randomUUID(), ...input };
336
+ },
337
+ });
338
+ ```
339
+
340
+ ### Event Tracking with `withEvents`
341
+
342
+ ```typescript
343
+ import { withEvents } from '@aeye/core';
344
+
345
+ const runner = withEvents({
346
+ onStatus: (instance) => {
347
+ console.log(`${instance.component.name}: ${instance.status}`);
348
+ if (instance.status === 'completed' && instance.completed && instance.running) {
349
+ console.log(` Took ${instance.completed - instance.running}ms`);
350
+ }
351
+ },
352
+
353
+ onPromptEvent: (_instance, event) => {
354
+ if (event.type === 'usage') {
355
+ console.log('Tokens used:', event.usage);
356
+ }
357
+ },
358
+ });
359
+
360
+ const result = await prompt.get(
361
+ 'result',
362
+ { text: 'Hello' },
363
+ { execute: yourAIExecutor, messages: [], runner }
364
+ );
365
+ ```
366
+
367
+ ### Token Management
368
+
369
+ The prompt automatically trims old messages when the context window is full:
370
+
371
+ ```typescript
372
+ const result = await prompt.get(
373
+ 'result',
374
+ { text: 'Query' },
375
+ {
376
+ execute: yourAIExecutor,
377
+ messages: conversationHistory, // Will be trimmed if needed
378
+
379
+ // Reserve this many tokens for the model's response
380
+ maxOutputTokens: 2048,
381
+
382
+ // Custom per-message token estimator
383
+ estimateUsage: (message) => ({
384
+ text: { input: Math.ceil((message.content as string).length / 4) }
385
+ }),
386
+ }
387
+ );
388
+ ```
389
+
390
+ ### Dynamic Reconfiguration
391
+
392
+ Use `reconfig` to adapt the prompt based on runtime statistics:
393
+
394
+ ```typescript
395
+ const adaptivePrompt = new Prompt({
396
+ name: 'adaptive',
397
+ description: 'Adapts based on runtime stats',
398
+ content: 'Solve: {{problem}}',
399
+ schema: z.object({ solution: z.string() }),
400
+
401
+ reconfig: (stats, _ctx) => {
402
+ // If many tool errors occurred, switch to one-at-a-time mode
403
+ if (stats.toolCallErrors > 3) {
404
+ return { config: { toolsOneAtATime: true } };
405
+ }
406
+ return {};
407
+ },
408
+ });
409
+ ```
410
+
411
+ ## Schema Fallbacks
412
+
413
+ A provider that rejects a schema throws `SchemaRejectedError` with a `target` naming the output or the tool. The prompt then steps down its `schemaFallbacks`, by default `SCHEMA_FALLBACKS`: the rejected schema goes non-strict, then the output moves to JSON mode with its schema in the prompt (`schemaDelivery: 'json'`), then to plain text with its schema in the prompt (`'prompt'`). Steps stay on the request for the rest of the run, and each emits a `schemaFallback` event.
414
+
415
+ ```typescript
416
+ import { Prompt, SCHEMA_FALLBACKS } from '@aeye/core';
417
+
418
+ const prompt = new Prompt({
419
+ name: 'extract',
420
+ description: 'Extracts fields',
421
+ content: 'Extract the fields from: {{text}}',
422
+ schema,
423
+ schemaFallbacks: SCHEMA_FALLBACKS.filter(step => step.name !== 'json'),
424
+ });
425
+ ```
426
+
427
+ Providers use `schemaRejection(request, error)` to classify a bad request and `deliverSchema(request)` to send a `json` or `prompt` schema without changing the prompt's request.
428
+
429
+ ## Error Kinds
430
+
431
+ Every prompt failure has `kind: 'model' | 'tool'`. `ToolInputError` (an unknown tool or bad arguments) and `OutputValidationError` (output that isn't JSON or fails the schema or `validate`) are model mistakes, fed back to the model for another try. `ToolExecutionError` is a tool's own throw. They appear on the `toolError` event's `failure`, on tool result messages' `failure`, and on the `textReset` event's `error`. A prompt that gives up throws a `PromptError` whose `cause` is the last failure.
432
+
433
+ ## Review
434
+
435
+ `review` judges the parsed, validated output before it's returned. Feedback becomes a user message and the model answers again, up to `reviewRetries` times; after that the prompt throws a `PromptError` caused by a `ReviewRejectedError` with the last output.
436
+
437
+ ```typescript
438
+ const answer = new Prompt({
439
+ name: 'answer',
440
+ description: 'Answers briefly',
441
+ content: 'Answer in under 20 words: {{question}}',
442
+ input: (input: { question: string }) => input,
443
+ review: (output) => output.split(' ').length < 20 ? { pass: true } : { feedback: 'Use fewer than 20 words.' },
444
+ });
445
+ ```
446
+
447
+ ## Model Capabilities
448
+
449
+ @aeye uses a capability system for automatic model selection. Models advertise their capabilities and the library selects the best match for each request:
450
+
451
+ | Capability | Description |
452
+ |------------|-------------|
453
+ | `chat` | Basic text completion |
454
+ | `streaming` | Real-time response streaming |
455
+ | `image` | Image generation |
456
+ | `vision` | Image understanding |
457
+ | `audio` | Text-to-speech |
458
+ | `hearing` | Speech-to-text |
459
+ | `embedding` | Text embeddings |
460
+ | `rerank` | Ordering documents by relevance |
461
+ | `decision` | Typed answers (probabilities, choices, scores) from decision models |
462
+ | `tools` | Function/tool calling |
463
+ | `json` | JSON output mode |
464
+ | `structured` | Structured outputs with schemas |
465
+ | `toolsStrict` | Strict-mode tool input schemas (auto-derived; see [Strict Mode](#strict-mode)) |
466
+ | `reasoning` | Extended chain-of-thought reasoning |
467
+ | `zdr` | Zero data retention |
468
+
469
+ ## Strict Mode
470
+
471
+ Each LLM provider that supports strict mode does so with its own JSON Schema dialect. `@aeye/core` reconciles them under a single `FormatDescriptor` so a Zod schema authored once works against any of them.
472
+
473
+ ### `strict` on Tool / Prompt
474
+
475
+ `Tool.strict` and `Prompt.strict` are `boolean | number`:
476
+
477
+ | Value | Meaning |
478
+ |---|---|
479
+ | `true` | **Require** strict. `@aeye/ai` filters selection down to models that declare strict support; the request fails if none qualify. |
480
+ | `false` | **Force** lenient. Standard JSON Schema; no `strict: true` on the wire. |
481
+ | `number > 0` (default `1`) | **Prefer** strict, accept fallback. Higher numbers win when there are more strict-requesting items than the chosen model's per-request budget allows. |
482
+ | omitted | Treated as `1`. |
483
+
484
+ ```typescript
485
+ new Tool({ name: 'must', /* default 1 */ schema: z.object({...}) }); // best-effort
486
+ new Tool({ name: 'critical', strict: true, schema: z.object({...}) }); // hard require
487
+ new Tool({ name: 'meh', strict: false, schema: z.object({...}) }); // never strict
488
+ new Tool({ name: 'top', strict: 100, schema: z.object({...}) }); // high priority
489
+ ```
490
+
491
+ Default `1` keeps "it just works" against unannotated models — strict where the chosen model supports it, lenient elsewhere. Set `true` only when strict is non-negotiable.
492
+
493
+ ### Native format descriptors
494
+
495
+ Nine descriptors ship out of the box:
496
+
497
+ | Descriptor | Family / Strict | Notes |
498
+ |---|---|---|
499
+ | `OPENAI_STRICT` | openai / strict | Records → array-of-pairs, tuples → numeric-key objects, `optional → T \| null`, closed objects, restricted format whitelist. |
500
+ | `ANTHROPIC_STRICT` | anthropic / strict | Closed objects, no recursion, no length / numeric range constraints, per-request slot budgets (20 strict tools / 24 optional params / 16 union types), format whitelist (OpenAI's nine plus `uri`). No spelling for "any" (see below), and structured output is always strict. |
501
+ | `GOOGLE_STRICT` | google / strict | `prefixItems`, `$ref: '#'` recursion, `propertyOrdering` emitted, restricted format whitelist. |
502
+ | `XAI_STRICT` | xai / strict | `anyOf` / `oneOf`, non-circular `$defs`, `prefixItems` tuples, open records, format whitelist with `uri`; no `allOf`. |
503
+ | `LENIENT` | lenient / non-strict | No rewrites, all features pass through. Default for unannotated models. |
504
+ | `OPENAI_NON_STRICT`, `ANTHROPIC_NON_STRICT`, `XAI_NON_STRICT` | family / non-strict | Aliased to `LENIENT` but tagged with the family for diagnostics. |
505
+ | `GOOGLE_NON_STRICT` | google / non-strict | `LENIENT` except `z.any()` is the empty schema `{}`: Gemini rejects a recursive `$defs/Any` whenever a tool call is forced. |
506
+
507
+ ```typescript
508
+ import { OPENAI_STRICT, ANTHROPIC_STRICT, getDescriptor, toJSONSchema } from '@aeye/core';
509
+
510
+ const schema = z.object({
511
+ name: z.string(),
512
+ tags: z.record(z.string(), z.string()),
513
+ });
514
+
515
+ // Same Zod, different wire shapes:
516
+ toJSONSchema(schema, OPENAI_STRICT); // tags → array of {key, value}
517
+ toJSONSchema(schema, ANTHROPIC_STRICT); // tags → array of {key, value} (open records unsupported)
518
+ toJSONSchema(schema, getDescriptor('google', true)); // tags → standard additionalProperties
519
+
520
+ // Backward-compat overloads still work:
521
+ toJSONSchema(schema, true); // === OPENAI_STRICT
522
+ toJSONSchema(schema, false); // === LENIENT
523
+ ```
524
+
525
+ Under every descriptor, `z.nullable()` admits `null` (as an `anyOf` branch where the dialect has `anyOf`), only formats JSON Schema defines are emitted (`.regex()`'s `regex` is dropped), and an open record with a plain string key leaves out `propertyNames`. `strictify` accepts schemas named with `.meta({ id })`.
526
+
527
+ ### When a schema can't go out strict
528
+
529
+ Some schemas fall back even under `strict: true`, because no allocation could make the API accept them:
530
+
531
+ - **Size.** A schema over the dialect's per-schema `schemaSizeLimits`, or whose optional parameters or unions alone exceed the per-request budget, degrades to the family's non-strict descriptor.
532
+ - **"Any" on Anthropic.** Anthropic strict rejects every spelling of "any JSON value" (`{}`, a typeless schema, `additionalProperties: true`, a recursive `$defs/Any`), so `ANTHROPIC_STRICT` uses `anyEncoding: 'none'`. A tool with `z.any()`, `z.unknown()` or a recursion cycle goes out non-strict; a structured output with one goes as prompt text (`canExpress` answers `false`).
533
+ - **Anthropic structured output.** Anthropic compiles every structured output, strict flag or not, so `ANTHROPIC_STRICT` sets `structuredOutputAlwaysStrict`. An output that doesn't fit the strict limits on its own is sent as prompt text rather than as a "non-strict" schema the API would still reject.
534
+
535
+ `SchemaBudget` takes separate tool and output descriptors (`new SchemaBudget(tool, output)`), so a model with strict structured output but no strict tools never gets `strict: true` tools, and the reverse.
536
+
537
+ ### Registering a custom descriptor
538
+
539
+ You can ship your own descriptor for a provider or dialect that isn't supported natively. Define it as a `FormatDescriptor` value and register it with `registerDescriptor(descriptor)`:
540
+
541
+ ```typescript
542
+ import { registerDescriptor, type FormatDescriptor, OPENAI_STRICT } from '@aeye/core';
543
+
544
+ const MY_PROVIDER_STRICT: FormatDescriptor = {
545
+ ...OPENAI_STRICT, // start from a known good baseline
546
+ id: 'my-provider-strict',
547
+ family: 'openai', // or 'anthropic' | 'google' | 'lenient'
548
+ // Override only the fields that differ from the baseline:
549
+ recordEncoding: 'open-record', // your provider supports additionalProperties: <schema>
550
+ tupleEncoding: 'prefix-items',
551
+ supportsRecursion: true,
552
+ };
553
+
554
+ registerDescriptor(MY_PROVIDER_STRICT);
555
+ ```
556
+
557
+ Once registered:
558
+
559
+ - Provider code can pass the descriptor to `toJSONSchema(schema, MY_PROVIDER_STRICT)` and `strictify(schema, MY_PROVIDER_STRICT)`.
560
+ - The Prompt's validation roundtrip (which looks up descriptors by id from the request) will resolve `'my-provider-strict'` correctly.
561
+ - A custom `Provider` implementation can return this descriptor from its `getStrictFormat`-equivalent helper.
562
+
563
+ The descriptor's full field list is exported as the `FormatDescriptor` interface — every flag is documented inline. Common adjustments:
564
+
565
+ | Field | Controls |
566
+ |---|---|
567
+ | `objectAllFieldsRequired` | Whether every property must appear in `required[]`. |
568
+ | `objectClosedByDefault` | Whether every object emits `additionalProperties: false`. |
569
+ | `recordEncoding` | `'array-of-pairs'` vs `'open-record'`. |
570
+ | `tupleEncoding` | `'object-numeric-keys'` vs `'prefix-items'` vs `'items-union'`. |
571
+ | `allowAllOf` / `allowAnyOf` / `allowOneOf` | JSON-Schema combinator support. |
572
+ | `supportedStringFormats` | `'all'` (every format JSON Schema defines) or a `Set<string>` whitelist (`'date-time'`, `'email'`, etc.). |
573
+ | `optionalAsNullable` | Whether `optional` fields are emitted as `T \| null` (OpenAI strict) vs omitted from `required[]`. |
574
+ | `supportsRecursion` | Whether `$ref` self-reference works under this dialect. |
575
+ | `anyEncoding` | How `z.any()` / `z.unknown()` is spelled: `'recursive-strict'`, `'recursive-open'`, `'flat'`, `'unconstrained'` (`{}`), or `'none'` (not sendable strict). |
576
+ | `structuredOutputAlwaysStrict` | The API compiles every structured output, so an output over the strict limits goes as prompt text instead of degrading. |
577
+ | `maxStrictTools` / `maxStrictOptionalParams` / `maxStrictUnionTypes` | Per-request budget caps; `undefined` means no documented limit. |
578
+ | `schemaSizeLimits` | Per-SCHEMA size ceilings (properties, nesting, total string characters, enum values); `undefined` means no documented limit. An over-size schema degrades on its own, even when it asked for `strict: true`. |
579
+
580
+ You don't need to set every field — spread an existing descriptor and override deltas. Tested via `analyzeSchema` + the schema test suite; see the strict-mode guide in `@aeye/docs` for end-to-end examples.
581
+
582
+ ## API Reference
583
+
584
+ ### Prompt
585
+
586
+ ```typescript
587
+ class Prompt<TContext, TMetadata, TName extends string, TInput extends object, TOutput extends object | string, TTools extends Tuple<ToolCompatible<TContext, TMetadata>>, TDecoded extends unknown = TOutput>
588
+ ```
589
+
590
+ `TDecoded` is the type a custom `parse` produces from the model's structured output (e.g. a built AST/class instance); it types `validate` and the decoded output. It defaults to `TOutput` (the wire type) when no custom `parse` is supplied — `schema` always stays the wire type.
591
+
592
+ **Constructor Options (`PromptInput`):**
593
+ - `name: string` - Unique identifier
594
+ - `description: string` - Purpose description
595
+ - `content: string` - Handlebars template for the system message
596
+ - `userTurn?: string` - User message added when there's no conversation (default: the context's, then `DEFAULT_USER_TURN`, `'Begin.'`)
597
+ - `input?: (input: TInput, ctx: Context<TContext, TMetadata>) => Record<string, any>` - Maps raw input to template variables
598
+ - `schema?: ZodType<TOutput> | ((input: TInput | undefined, ctx: Context<TContext, TMetadata>) => ZodType<TOutput> | false)` - Output schema; `false` disables structured output
599
+ - `strict?: boolean | number` - Strict output schema: `true` requires, `false` disables, a number is a preference priority (default: `1`)
600
+ - `config?: Partial<Request> | ((input: TInput | undefined, ctx: Context<TContext, TMetadata>) => Partial<Request> | false)` - AI request overrides
601
+ - `tools?: TTools` - Tools the prompt may call
602
+ - `toolExecution?: 'sequential' | 'parallel' | 'immediate'` - Tool execution mode
603
+ - `toolRetries?: number` - Retry count for failed tools (default: 2)
604
+ - `toolIterations?: number` - Max tool-calling iterations (default: 3)
605
+ - `toolsMax?: number` - Max successful tool calls
606
+ - `outputRetries?: number` - Retry count for invalid structured outputs
607
+ - `schemaDelivery?: 'auto' | 'structured' | 'json' | 'prompt'` - How the output schema reaches the model (default: `'auto'`)
608
+ - `schemaFallbacks?: readonly SchemaFallback[]` - Steps taken when a provider rejects a schema (default: the context's, then `SCHEMA_FALLBACKS`)
609
+ - `metadata?: TMetadata` - Static metadata for this prompt
610
+ - `validate?: (output: TOutput, ctx: Context<TContext, TMetadata>) => void | Promise<void>` - Post-parse validation
611
+ - `review?: (output: TDecoded, context: PromptReviewContext) => PromptReview | Promise<PromptReview>` - Review the valid output: `{ pass: true }` or `{ feedback }` for another round
612
+ - `reviewRetries?: number` - Rounds the model gets to answer review feedback (default: 1)
613
+ - `applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean>` - Availability check
614
+
615
+ **Methods:**
616
+ - `get(mode: PromptGetType, input?: TInput, ctx?: Context<TContext, TMetadata>): PromptGet<...>` - Execute and return output in the specified mode
617
+ - `run(input?: TInput, ctx?: Context<TContext, TMetadata>): AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput | undefined>` - Full streaming generator
618
+ - `applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean>` - Check if prompt is usable
619
+
620
+ ### Tool
621
+
622
+ ```typescript
623
+ class Tool<TContext, TMetadata, TName extends string, TParams extends object, TOutput, TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>>, TDecoded extends unknown = TParams>
624
+ ```
625
+
626
+ `TDecoded` is the type a custom `parse` produces from the raw wire args (e.g. a built class instance); it types `call`, `validate`, and `metadataFn`. It defaults to `TParams` (the wire type) when no custom `parse` is supplied — `schema` always stays the wire type.
627
+
628
+ **Constructor Options (`ToolInput`):**
629
+ - `name: string` - Unique identifier (shown to the AI model)
630
+ - `description: string` - What the tool does (shown to the AI model)
631
+ - `instructions?: string` - Handlebars template for usage instructions
632
+ - `schema: ZodType<TParams> | ((ctx: Context<TContext, TMetadata>) => ZodType<TParams> | undefined)` - Input parameter schema
633
+ - `strict?: boolean | number` - Strict input schema: `true` requires, `false` disables, a number is a preference priority (default: `1`)
634
+ - `format?: ToolTextFormat` - Take free text (`{ type: 'text' }`) or grammar-constrained text (`{ type: 'grammar', syntax: 'lark' | 'regex', definition }`) as `input` instead of JSON; use `TEXT_TOOL_SCHEMA` as the schema. Providers without free-text tools send a one-field function whose description `describeTool(definition)` builds, with the grammar appended
635
+ - `defer?: boolean` - Load only when the model searches for it (OpenAI tool search)
636
+ - `async?: boolean` - The model keeps working while the tool runs (OpenAI Responses)
637
+ - `refs?: TRefs` - Referenced components
638
+ - `call: (input: TParams, refs: TRefs, ctx: Context<TContext, TMetadata>) => TOutput` - Implementation
639
+ - `validate?: (input: TParams, ctx: Context<TContext, TMetadata>) => void | Promise<void>` - Post-parse validation
640
+ - `applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean>` - Availability check
641
+
642
+ **Methods:**
643
+ - `run(input?: TParams, ctx?: Context<TContext, TMetadata>): TOutput` - Execute the tool directly
644
+ - `compile(ctx: Context<TContext, TMetadata>): Promise<readonly [string, ToolDefinition] | undefined>` - Generate AI tool definition
645
+ - `applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean>` - Check if tool is usable
646
+
647
+ ### Agent
648
+
649
+ ```typescript
650
+ class Agent<TContext, TMetadata, TName extends string, TInput extends object, TOutput, TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>>>
651
+ ```
652
+
653
+ **Constructor Options (`AgentInput`):**
654
+ - `name: string` - Unique identifier
655
+ - `description: string` - Purpose description
656
+ - `refs: TRefs` - Components this agent uses
657
+ - `call: (input: TInput, refs: TRefs, ctx: Context<TContext, TMetadata>) => TOutput` - Implementation
658
+ - `applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean>` - Availability check
659
+
660
+ **Methods:**
661
+ - `run(input?: TInput, ctx?: Context<TContext, TMetadata>): TOutput` - Execute the agent
662
+ - `applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean>` - Check if agent is usable
663
+
664
+ ## Context Structure
665
+
666
+ The `Context<TContext, TMetadata>` type combines your custom context with the core execution context:
667
+
668
+ ```typescript
669
+ // Core fields available in every context
670
+ interface CoreContextFields {
671
+ // AI executor for non-streaming requests
672
+ execute?: (request: Request, ctx: TContext, metadata?: TMetadata, signal?: AbortSignal) => Promise<Response>;
673
+ // AI streamer for streaming requests
674
+ stream?: (request: Request, ctx: TContext, metadata?: TMetadata, signal?: AbortSignal) => AsyncGenerator<Chunk, Response>;
675
+
676
+ // Conversation history passed to the model
677
+ messages?: Message[];
678
+
679
+ // Reserve N tokens for the model's output (used for context trimming)
680
+ maxOutputTokens?: number;
681
+ // Override context window size (used for context trimming)
682
+ contextWindow?: number;
683
+
684
+ // Custom per-message usage estimator
685
+ estimateUsage?: (message: Message) => Usage | undefined;
686
+
687
+ // Number of output-format retries (default: 2)
688
+ outputRetries?: number;
689
+ // Number of context-trimming retries (default: 1)
690
+ forgetRetries?: number;
691
+ // Steps a prompt takes when a provider rejects a schema (default: SCHEMA_FALLBACKS)
692
+ schemaFallbacks?: readonly SchemaFallback[];
693
+ // User message a prompt adds when there's no conversation (default: DEFAULT_USER_TURN, 'Begin.')
694
+ userTurn?: string;
695
+
696
+ // Abort signal for cancellation
697
+ signal?: AbortSignal;
698
+ // Event runner for tracking component lifecycle
699
+ runner?: Events;
700
+ }
701
+ ```
702
+
703
+ ## Request Configuration
704
+
705
+ ```typescript
706
+ interface Request {
707
+ messages: Message[];
708
+ temperature?: number; // 0.0–2.0
709
+ topP?: number; // 0.0–1.0
710
+ maxTokens?: number;
711
+ stop?: string | string[];
712
+ tools?: ToolDefinition[];
713
+ toolChoice?: 'auto' | 'required' | 'none' | { tool: string } | { allowed: string[]; required?: boolean };
714
+ responseFormat?: 'text' | 'json' | { type: ZodType; strict: boolean | number };
715
+ reason?: { effort?: ReasoningEffort; maxTokens?: number; summary?: 'auto' | 'concise' | 'detailed'; exclude?: boolean };
716
+ frequencyPenalty?: number;
717
+ presencePenalty?: number;
718
+ seed?: number;
719
+ // ... more options
720
+ }
721
+ ```
722
+
723
+ ## Best Practices
724
+
725
+ 1. **Type Safety** - Use TypeScript generics for context and metadata
726
+ 2. **Schema Validation** - Use Zod for robust input/output validation
727
+ 3. **Token Management** - Supply `estimateUsage` for accurate context trimming
728
+ 4. **Tool Organization** - Group related tools and use agents to orchestrate them
729
+ 5. **Testing** - Unit test tools and prompts independently
730
+ 6. **Context Minimization** - Pass only necessary data in context
731
+ 7. **Streaming** - Use streaming for better UX with long responses
732
+ 8. **Validation** - Use `validate` hooks for business-logic checks after schema parsing
733
+
734
+ ## Examples
735
+
736
+ See the `src/__tests__` directory for comprehensive examples:
737
+
738
+ - **prompt-core-features.test.ts** - Basic prompt usage
739
+ - **prompt-streaming-tool-events.test.ts** - Streaming and events
740
+ - **tool.test.ts** - Tool creation and usage
741
+ - **agent.test.ts** - Agent orchestration
742
+ - **context-propagation.test.ts** - Context handling
743
+
744
+ ## TypeScript Support
745
+
746
+ Full type inference across component hierarchies:
747
+
748
+ ```typescript
749
+ import { Prompt, Tool } from '@aeye/core';
750
+ import z from 'zod';
751
+
752
+ // Output type is inferred from the schema
753
+ const prompt = new Prompt({
754
+ name: 'example',
755
+ description: 'Example prompt',
756
+ content: 'Say hello to {{name}}.',
757
+ input: (input: { name: string }) => ({ name: input.name }),
758
+ schema: z.object({ greeting: z.string() }),
759
+ });
760
+
761
+ // result is typed as { greeting: string } | undefined
762
+ const result = await prompt.get('result', { name: 'World' }, ctx);
763
+ console.log(result?.greeting);
764
+
765
+ // Tool call parameters are type-safe
766
+ const mathTool = new Tool({
767
+ name: 'add',
768
+ description: 'Adds two numbers',
769
+ schema: z.object({ a: z.number(), b: z.number() }),
770
+ call: (input, _refs, _ctx) => {
771
+ // input is typed as { a: number; b: number }
772
+ return input.a + input.b;
773
+ },
774
+ });
775
+ ```
776
+
777
+ ## Contributing
778
+
779
+ Contributions are welcome! See the main [@aeye repository](https://github.com/ClickerMonkey/aeye) for contribution guidelines.
780
+
781
+ ## License
782
+
783
+ GPL-3.0 © [ClickerMonkey](https://github.com/ClickerMonkey)
784
+
785
+ ## Links
786
+
787
+ - [Root README](../../README.md)
788
+ - [@aeye/ai README](../ai/README.md)
789
+ - [GitHub Repository](https://github.com/ClickerMonkey/aeye)
790
+ - [Issue Tracker](https://github.com/ClickerMonkey/aeye/issues)