@positronic/template-new-project 0.0.76 → 0.0.78
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/index.js +5 -4
- package/package.json +1 -1
- package/template/.positronic/build-brains.mjs +93 -0
- package/template/.positronic/bundle.ts +1 -1
- package/template/.positronic/src/index.ts +6 -1
- package/template/.positronic/wrangler.jsonc +4 -0
- package/template/CLAUDE.md +149 -50
- package/template/docs/brain-dsl-guide.md +661 -510
- package/template/docs/brain-testing-guide.md +63 -3
- package/template/docs/memory-guide.md +116 -100
- package/template/docs/plugin-guide.md +218 -0
- package/template/docs/positronic-guide.md +99 -78
- package/template/docs/tips-for-agents.md +179 -79
- package/template/src/brain.ts +73 -0
- package/template/src/brains/hello.ts +46 -0
- package/template/{runner.ts → src/runner.ts} +9 -12
- package/template/tests/example.test.ts +1 -1
- package/template/tests/test-utils.ts +1 -4
- package/template/tsconfig.json +4 -2
- package/template/brain.ts +0 -96
- package/template/brains/hello.ts +0 -44
- /package/template/{brains → src/brains}/example.ts +0 -0
- /package/template/{components → src/components}/index.ts +0 -0
- /package/template/{utils → src/utils}/bottleneck.ts +0 -0
- /package/template/{webhooks → src/webhooks}/.gitkeep +0 -0
|
@@ -7,23 +7,28 @@ This guide covers project-level patterns and best practices for Positronic appli
|
|
|
7
7
|
A typical Positronic project has the following structure:
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
├──
|
|
11
|
-
├──
|
|
10
|
+
├── src/
|
|
11
|
+
│ ├── brain.ts # Project brain wrapper
|
|
12
|
+
│ ├── brains/ # Brain definitions
|
|
13
|
+
│ ├── plugins/ # Plugin definitions (webhooks, tools, services)
|
|
14
|
+
│ ├── runner.ts # Local runner for development
|
|
15
|
+
│ ├── services/ # Service implementations
|
|
16
|
+
│ ├── utils/ # Shared utilities
|
|
17
|
+
│ └── components/ # Reusable UI/prompt components
|
|
12
18
|
├── resources/ # Files accessible to brains
|
|
13
19
|
├── tests/ # Test files
|
|
14
20
|
├── docs/ # Documentation
|
|
15
|
-
├── runner.ts # Local runner for development
|
|
16
21
|
└── positronic.config.json # Project configuration
|
|
17
22
|
```
|
|
18
23
|
|
|
19
24
|
## The Project Brain Pattern
|
|
20
25
|
|
|
21
|
-
Every Positronic project includes a `brain.ts` file
|
|
26
|
+
Every Positronic project includes a `src/brain.ts` file. This file exports a custom `brain` function that wraps the core Positronic brain function.
|
|
22
27
|
|
|
23
28
|
### Why Use a Project Brain?
|
|
24
29
|
|
|
25
30
|
The project brain pattern provides a single place to:
|
|
26
|
-
- Configure
|
|
31
|
+
- Configure plugins that all brains can access
|
|
27
32
|
- Set up logging, monitoring, or analytics
|
|
28
33
|
- Add authentication or API clients
|
|
29
34
|
- Establish project-wide conventions
|
|
@@ -33,54 +38,61 @@ The project brain pattern provides a single place to:
|
|
|
33
38
|
All brains in your project should import from `../brain.js` instead of `@positronic/core`:
|
|
34
39
|
|
|
35
40
|
```typescript
|
|
36
|
-
// ✅ DO THIS (from a file in
|
|
41
|
+
// ✅ DO THIS (from a file in src/brains/)
|
|
37
42
|
import { brain } from '../brain.js';
|
|
38
43
|
|
|
39
44
|
// ❌ NOT THIS
|
|
40
45
|
import { brain } from '@positronic/core';
|
|
41
46
|
```
|
|
42
47
|
|
|
43
|
-
### Configuring
|
|
48
|
+
### Configuring Plugins
|
|
44
49
|
|
|
45
|
-
To add project-wide
|
|
50
|
+
To add project-wide plugins, modify the `src/brain.ts` file using `createBrain()`. Plugins are defined with `definePlugin` and passed to `createBrain({ plugins: [...] })`:
|
|
46
51
|
|
|
47
52
|
```typescript
|
|
48
|
-
|
|
53
|
+
// src/plugins/logger.ts
|
|
54
|
+
import { definePlugin } from '@positronic/core';
|
|
55
|
+
|
|
56
|
+
export const logger = definePlugin({
|
|
57
|
+
name: 'logger',
|
|
58
|
+
create: () => ({
|
|
59
|
+
info: (msg: string) => console.log(`[<%= '${new Date().toISOString()}' %>] INFO: <%= '${msg}' %>`),
|
|
60
|
+
error: (msg: string) => console.error(`[<%= '${new Date().toISOString()}' %>] ERROR: <%= '${msg}' %>`),
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
```
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
get: (key: string) =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
65
|
+
```typescript
|
|
66
|
+
// src/plugins/database.ts
|
|
67
|
+
import { definePlugin } from '@positronic/core';
|
|
68
|
+
|
|
69
|
+
export const database = definePlugin({
|
|
70
|
+
name: 'database',
|
|
71
|
+
create: () => ({
|
|
72
|
+
get: async (key: string) => {
|
|
73
|
+
// Your database implementation
|
|
74
|
+
return localStorage.getItem(key);
|
|
75
|
+
},
|
|
76
|
+
set: async (key: string, value: any) => {
|
|
77
|
+
// Your database implementation
|
|
78
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// src/brain.ts
|
|
86
|
+
import { createBrain } from '@positronic/core';
|
|
87
|
+
import { logger } from './plugins/logger.js';
|
|
88
|
+
import { database } from './plugins/database.js';
|
|
61
89
|
|
|
62
|
-
// Export the project brain factory
|
|
63
90
|
export const brain = createBrain({
|
|
64
|
-
|
|
65
|
-
logger: {
|
|
66
|
-
info: (msg) => console.log(`[<%= '${new Date().toISOString()}' %>] INFO: <%= '${msg}' %>`),
|
|
67
|
-
error: (msg) => console.error(`[<%= '${new Date().toISOString()}' %>] ERROR: <%= '${msg}' %>`)
|
|
68
|
-
},
|
|
69
|
-
database: {
|
|
70
|
-
get: async (key) => {
|
|
71
|
-
// Your database implementation
|
|
72
|
-
return localStorage.getItem(key);
|
|
73
|
-
},
|
|
74
|
-
set: async (key, value) => {
|
|
75
|
-
// Your database implementation
|
|
76
|
-
localStorage.setItem(key, JSON.stringify(value));
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
91
|
+
plugins: [logger, database],
|
|
80
92
|
});
|
|
81
93
|
```
|
|
82
94
|
|
|
83
|
-
Now all brains automatically have access to these
|
|
95
|
+
Now all brains automatically have access to these plugins:
|
|
84
96
|
|
|
85
97
|
```typescript
|
|
86
98
|
import { brain } from '../brain.js';
|
|
@@ -120,7 +132,7 @@ See `/docs/brain-testing-guide.md` for detailed testing guidance.
|
|
|
120
132
|
## Development Workflow
|
|
121
133
|
|
|
122
134
|
1. **Start the development server**: `px server -d`
|
|
123
|
-
2. **Create or modify brains**: Always import from
|
|
135
|
+
2. **Create or modify brains**: Always import from `../brain.js` (from files in `src/brains/`)
|
|
124
136
|
3. **Test locally**:
|
|
125
137
|
```bash
|
|
126
138
|
# Basic run
|
|
@@ -162,7 +174,7 @@ CLOUDFLARE_API_TOKEN=your-api-token
|
|
|
162
174
|
|
|
163
175
|
## Best Practices
|
|
164
176
|
|
|
165
|
-
1. **
|
|
177
|
+
1. **Plugins**: Configure once in `src/brain.ts`, use everywhere
|
|
166
178
|
2. **Resources**: Use for content that non-developers should be able to edit
|
|
167
179
|
3. **Secrets**: Never commit API keys; use environment variables
|
|
168
180
|
4. **Organization**: Group related brains in folders as your project grows
|
|
@@ -174,14 +186,25 @@ CLOUDFLARE_API_TOKEN=your-api-token
|
|
|
174
186
|
### Logging and Monitoring
|
|
175
187
|
|
|
176
188
|
```typescript
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
// src/plugins/metrics.ts
|
|
190
|
+
import { definePlugin } from '@positronic/core';
|
|
191
|
+
|
|
192
|
+
export const metrics = definePlugin({
|
|
193
|
+
name: 'metrics',
|
|
194
|
+
create: () => ({
|
|
195
|
+
track: (event: string, properties?: any) => {
|
|
196
|
+
// Your analytics implementation
|
|
197
|
+
console.log('track', event, properties);
|
|
198
|
+
},
|
|
199
|
+
time: (label: string) => {
|
|
200
|
+
const start = Date.now();
|
|
201
|
+
return () => console.log(label, Date.now() - start, 'ms');
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
```
|
|
184
206
|
|
|
207
|
+
```typescript
|
|
185
208
|
// In your brain
|
|
186
209
|
export default brain('Analytics Brain')
|
|
187
210
|
.step('Start Timer', ({ metrics }) => {
|
|
@@ -202,34 +225,32 @@ export default brain('Analytics Brain')
|
|
|
202
225
|
### API Integration
|
|
203
226
|
|
|
204
227
|
```typescript
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
};
|
|
228
|
+
// src/plugins/api.ts
|
|
229
|
+
import { definePlugin } from '@positronic/core';
|
|
230
|
+
|
|
231
|
+
export const api = definePlugin({
|
|
232
|
+
name: 'api',
|
|
233
|
+
setup: (config: { baseUrl: string; apiKey: string }) => config,
|
|
234
|
+
create: ({ config }) => ({
|
|
235
|
+
get: async (path: string) => {
|
|
236
|
+
const response = await fetch(`<%= '${config!.baseUrl}${path}' %>`, {
|
|
237
|
+
headers: { 'Authorization': `Bearer <%= '${config!.apiKey}' %>` }
|
|
238
|
+
});
|
|
239
|
+
return response.json();
|
|
240
|
+
},
|
|
241
|
+
post: async (path: string, data: any) => {
|
|
242
|
+
const response = await fetch(`<%= '${config!.baseUrl}${path}' %>`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: {
|
|
245
|
+
'Authorization': `Bearer <%= '${config!.apiKey}' %>`,
|
|
246
|
+
'Content-Type': 'application/json'
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify(data)
|
|
249
|
+
});
|
|
250
|
+
return response.json();
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
233
254
|
```
|
|
234
255
|
|
|
235
256
|
## currentUser
|
|
@@ -247,8 +268,8 @@ The way `currentUser` is provided depends on how the brain is running:
|
|
|
247
268
|
**Local development with `runner.ts`**: When calling `runner.run()` directly, you must pass `currentUser`:
|
|
248
269
|
|
|
249
270
|
```typescript
|
|
250
|
-
import { runner } from './runner.js';
|
|
251
|
-
import myBrain from './brains/my-brain.js';
|
|
271
|
+
import { runner } from './src/runner.js';
|
|
272
|
+
import myBrain from './src/brains/my-brain.js';
|
|
252
273
|
|
|
253
274
|
await runner.run(myBrain, {
|
|
254
275
|
currentUser: { name: 'local-dev-user' },
|
|
@@ -286,6 +307,6 @@ export default brain('greet')
|
|
|
286
307
|
|
|
287
308
|
- **Documentation**: https://positronic.dev
|
|
288
309
|
- **CLI Help**: `px --help`
|
|
289
|
-
- **Brain DSL Guide**: `/docs/brain-dsl-guide.md` (includes
|
|
310
|
+
- **Brain DSL Guide**: `/docs/brain-dsl-guide.md` (includes page steps for generating forms)
|
|
290
311
|
- **Memory Guide**: `/docs/memory-guide.md`
|
|
291
312
|
- **Testing Guide**: `/docs/brain-testing-guide.md`
|
|
@@ -31,11 +31,11 @@ This also applies to variable declarations and function parameters:
|
|
|
31
31
|
```typescript
|
|
32
32
|
// ❌ DON'T DO THIS
|
|
33
33
|
const names: string[] = options.notify.split(',');
|
|
34
|
-
|
|
34
|
+
message: ({ state }: any) => { ... }
|
|
35
35
|
|
|
36
36
|
// ✅ DO THIS
|
|
37
37
|
const names = options.notify.split(',');
|
|
38
|
-
|
|
38
|
+
message: ({ state }) => { ... }
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
If you genuinely need a cast to fix a type error, prefer the narrowest cast possible and add it only after seeing the error.
|
|
@@ -199,10 +199,10 @@ The same applies to prompt templates:
|
|
|
199
199
|
|
|
200
200
|
```typescript
|
|
201
201
|
// ❌ DON'T DO THIS
|
|
202
|
-
|
|
202
|
+
message: ({ state }) => `Hello <%= '${state.user.name}' %>, your order <%= '${state.order.id}' %> is ready.`,
|
|
203
203
|
|
|
204
204
|
// ✅ DO THIS
|
|
205
|
-
|
|
205
|
+
message: ({ state: { user, order } }) => `Hello <%= '${user.name}' %>, your order <%= '${order.id}' %> is ready.`,
|
|
206
206
|
```
|
|
207
207
|
|
|
208
208
|
When you still need `state` (e.g. for `...state` in the return value), destructure in the function body instead:
|
|
@@ -224,6 +224,27 @@ When you still need `state` (e.g. for `...state` in the return value), destructu
|
|
|
224
224
|
})
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
+
## JSX for Prompt Templates
|
|
228
|
+
|
|
229
|
+
For complex, multi-line prompts, use JSX instead of template literals. Rename the file to `.tsx` and return JSX from the template function:
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
// Before (template literal — hard to read when indented in builder chain)
|
|
233
|
+
message: ({ state: { user, order } }) =>
|
|
234
|
+
`Hello <%= '${user.name}' %>, your order <%= '${order.id}' %> is ready.
|
|
235
|
+
<%= '${order.isExpress ? "\\nThis is an express order." : ""}' %>`,
|
|
236
|
+
|
|
237
|
+
// After (JSX — Prettier manages indentation, conditionals are clean)
|
|
238
|
+
message: ({ state: { user, order } }) => (
|
|
239
|
+
<>
|
|
240
|
+
Hello {user.name}, your order {order.id} is ready.
|
|
241
|
+
{order.isExpress && <>This is an express order.</>}
|
|
242
|
+
</>
|
|
243
|
+
)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
See `/docs/brain-dsl-guide.md` for full JSX template documentation including loops, reusable components, and async components for resource loading.
|
|
247
|
+
|
|
227
248
|
## State Shape
|
|
228
249
|
|
|
229
250
|
### Each step should have one clear purpose, and add one thing to state
|
|
@@ -296,7 +317,53 @@ Use `.values` for simple extraction, `.filter()` for correlated filtering, and `
|
|
|
296
317
|
}))
|
|
297
318
|
```
|
|
298
319
|
|
|
299
|
-
**Name the `
|
|
320
|
+
**Name the `stateKey` after the content.** The stateKey is now the 2nd argument to `.map()`. If results contain analyses, use `.map('title', 'analyses', { ... })`, not `.map('title', 'processedItems', { ... })`.
|
|
321
|
+
|
|
322
|
+
### Naming convention for filter/map parameters
|
|
323
|
+
|
|
324
|
+
`IterateResult.filter()` and `.map()` take two parameters: the input item and the AI result. **Name them after what they represent**, not generic names like `item` and `r`:
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
// ❌ DON'T DO THIS - generic parameter names
|
|
328
|
+
state.validations.filter((item, r) => r.matches)
|
|
329
|
+
state.transcripts.filter((t) => t.hasTranscript) // WRONG: single param is the item, not the result
|
|
330
|
+
|
|
331
|
+
// ✅ DO THIS - descriptive names that reflect the data
|
|
332
|
+
state.validations.filter((crawledResult, validation) => validation.matches)
|
|
333
|
+
state.transcripts.filter((match, extraction) => extraction.hasTranscript)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The first parameter is always the input item (what you passed to `over`), and the second is the AI's output (what the `outputSchema` describes). A single-parameter callback only receives the item — if you need the AI result, you must use both parameters.
|
|
337
|
+
|
|
338
|
+
### Nested brain state spreading
|
|
339
|
+
|
|
340
|
+
When a `.brain()` step runs an inner brain, the inner brain's final state is spread directly onto the outer state:
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
.brain('Search and validate', searchAndValidate)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
All properties from the inner brain's final state are merged onto the outer state. Subsequent steps can access those properties directly (e.g., `state.matches`). The inner brain's state type is fully inferred from its definition, so you get full type safety.
|
|
347
|
+
|
|
348
|
+
If you want namespacing (to avoid collisions with existing state properties), design the inner brain to return a namespaced state shape:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
// Inner brain returns its results under a single key
|
|
352
|
+
const searchAndValidate = brain('search-and-validate')
|
|
353
|
+
.step('Search', ({ state }) => ({ ...state, matches: [] }))
|
|
354
|
+
.step('Package', ({ state }) => ({
|
|
355
|
+
searchResults: { matches: state.matches }
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
// Outer brain accesses via state.searchResults.matches
|
|
359
|
+
brain('parent')
|
|
360
|
+
.step('Init', () => ({ query: 'test' }))
|
|
361
|
+
.brain('Search and validate', searchAndValidate)
|
|
362
|
+
.step('Use results', ({ state }) => ({
|
|
363
|
+
...state,
|
|
364
|
+
count: state.searchResults.matches.length,
|
|
365
|
+
}));
|
|
366
|
+
```
|
|
300
367
|
|
|
301
368
|
## Brain DSL Type Inference
|
|
302
369
|
|
|
@@ -328,6 +395,30 @@ brain('example')
|
|
|
328
395
|
|
|
329
396
|
The type inference flows through the entire chain, making the code cleaner and more maintainable.
|
|
330
397
|
|
|
398
|
+
### Brains that receive initial state from outside
|
|
399
|
+
|
|
400
|
+
If a brain receives its initial state from the outside — via `.map()`, `.brain()`, or `run({ initialState })` — declare the state type in the generic parameters:
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
// This brain is used inside .map() — it receives thread data as initial state
|
|
404
|
+
const categorizeBrain = brain<{}, RawThread>('categorize-thread')
|
|
405
|
+
.prompt('Categorize', {
|
|
406
|
+
message: ({ state }) => `Categorize: <%= '${state.subject}' %>`,
|
|
407
|
+
outputSchema: z.object({ category: z.string() }),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// The parent brain maps over threads
|
|
411
|
+
brain('email-digest')
|
|
412
|
+
.step('Fetch', async () => ({ threads: await fetchThreads() }))
|
|
413
|
+
.map('Categorize', 'categorized', {
|
|
414
|
+
run: categorizeBrain,
|
|
415
|
+
over: ({ state }) => state.threads,
|
|
416
|
+
initialState: (thread) => thread,
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Without the generic, `TState` defaults to `object` and steps can't see any properties. The generic tells TypeScript "this brain starts with this shape." If the brain builds its own state from nothing (first step returns the initial shape), skip the generic — inference handles it.
|
|
421
|
+
|
|
331
422
|
## Error Handling in Brains
|
|
332
423
|
|
|
333
424
|
**Important**: Do NOT catch errors in brain steps unless error handling is specifically part of the brain's workflow logic. The brain runner handles all errors automatically.
|
|
@@ -375,13 +466,9 @@ brain('validation-example')
|
|
|
375
466
|
|
|
376
467
|
Most generated brains should not have try-catch blocks. Only use them when the error state is meaningful to subsequent steps in the workflow.
|
|
377
468
|
|
|
378
|
-
##
|
|
469
|
+
## Page Steps for Form Generation
|
|
379
470
|
|
|
380
|
-
When you need to collect user input, use the `.
|
|
381
|
-
1. `.ui()` generates the page
|
|
382
|
-
2. Next step gets `page.url` and `page.webhook`
|
|
383
|
-
3. Notify users, then use `.wait()` with `page.webhook`
|
|
384
|
-
4. Step after `.wait()` gets form data in `response`
|
|
471
|
+
When you need to collect user input, use the `.page()` method with `formSchema`. The brain auto-suspends after generating the page, then auto-merges the form response directly onto state. Use the `onCreated` callback for side effects.
|
|
385
472
|
|
|
386
473
|
```typescript
|
|
387
474
|
import { z } from 'zod';
|
|
@@ -391,74 +478,98 @@ brain('feedback-collector')
|
|
|
391
478
|
...state,
|
|
392
479
|
userName: 'John',
|
|
393
480
|
}))
|
|
394
|
-
// Generate the form
|
|
395
|
-
.
|
|
396
|
-
|
|
481
|
+
// Generate the form, onCreated, auto-suspend, auto-merge response
|
|
482
|
+
.page('Collect Feedback', ({ state, slack }) => ({
|
|
483
|
+
prompt: <%= '\`' %>
|
|
397
484
|
Create a feedback form for <%= '${state.userName}' %>:
|
|
398
485
|
- Rating (1-5)
|
|
399
486
|
- Comments textarea
|
|
400
487
|
- Submit button
|
|
401
488
|
<%= '\`' %>,
|
|
402
|
-
|
|
489
|
+
formSchema: z.object({
|
|
403
490
|
rating: z.number().min(1).max(5),
|
|
404
491
|
comments: z.string(),
|
|
405
492
|
}),
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
})
|
|
412
|
-
// Wait for form submission
|
|
413
|
-
.wait('Wait for submission', ({ page }) => page.webhook)
|
|
414
|
-
// Form data comes through response (not page)
|
|
415
|
-
.step('Process', ({ state, response }) => ({
|
|
493
|
+
onCreated: async (page) => {
|
|
494
|
+
await slack.post('#feedback', `Fill out: <%= '${page.url}' %>`);
|
|
495
|
+
},
|
|
496
|
+
}))
|
|
497
|
+
// No .handle() needed — form data auto-merges directly onto state
|
|
498
|
+
.step('Process', ({ state }) => ({
|
|
416
499
|
...state,
|
|
417
|
-
|
|
418
|
-
|
|
500
|
+
// state.rating and state.comments are typed
|
|
501
|
+
processed: true,
|
|
419
502
|
}));
|
|
420
503
|
```
|
|
421
504
|
|
|
422
505
|
Key points:
|
|
506
|
+
- `page` is available inside the `onCreated` callback, not in a separate step
|
|
423
507
|
- `page.url` - where to send users
|
|
424
|
-
-
|
|
425
|
-
-
|
|
426
|
-
- You control how users are notified (Slack, email, etc.)
|
|
508
|
+
- The brain auto-suspends after `.page()` with `formSchema`
|
|
509
|
+
- Form data is spread directly onto state (e.g., `state.rating`, `state.comments`)
|
|
510
|
+
- You control how users are notified (Slack, email, etc.) inside `onCreated`
|
|
511
|
+
|
|
512
|
+
See `/docs/brain-dsl-guide.md` for more page step examples.
|
|
427
513
|
|
|
428
|
-
|
|
514
|
+
### Use `.page()` for Data Display, Not `generatePage` in Loops
|
|
429
515
|
|
|
430
|
-
|
|
516
|
+
When a brain needs to display data and collect user input, prefer `.page()` steps over calling the `generatePage` tool inside a `.prompt()` loop. The `.page()` step passes data via `props` — the page generation LLM never sees the actual data, only its shape. This is more reliable and keeps data out of the LLM context.
|
|
431
517
|
|
|
432
|
-
|
|
518
|
+
```typescript
|
|
519
|
+
// ❌ DON'T DO THIS - LLM fetches data then passes it to generatePage
|
|
520
|
+
brain('reader')
|
|
521
|
+
.prompt('Show Articles', () => ({
|
|
522
|
+
message: 'Fetch articles and create a page showing them',
|
|
523
|
+
outputSchema: z.object({ articlesShown: z.number() }),
|
|
524
|
+
loop: { tools: { fetchArticles, generatePage, waitForWebhook } },
|
|
525
|
+
}))
|
|
433
526
|
|
|
527
|
+
// ✅ DO THIS - fetch data in a step, display with .page()
|
|
528
|
+
brain('reader')
|
|
529
|
+
.step('Fetch Articles', async () => {
|
|
530
|
+
const articles = await fetchArticles();
|
|
531
|
+
return { articles };
|
|
532
|
+
})
|
|
533
|
+
.page('Show Articles', ({ state }) => ({
|
|
534
|
+
prompt: 'Create a reading list with checkboxes to mark articles as read',
|
|
535
|
+
props: { articles: state.articles },
|
|
536
|
+
formSchema: z.object({
|
|
537
|
+
readArticleIds: z.array(z.string()),
|
|
538
|
+
}),
|
|
539
|
+
}))
|
|
434
540
|
```
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
541
|
+
|
|
542
|
+
The `.page()` step automatically suspends, waits for the form submission, and merges the response onto state. Use `.prompt()` loops for tasks that genuinely need LLM decision-making with tools (research, multi-step reasoning, human-in-the-loop via webhooks), not for data display.
|
|
543
|
+
|
|
544
|
+
## Plugin Organization
|
|
545
|
+
|
|
546
|
+
When implementing plugins for the project brain, keep plugin implementations in the `src/plugins/` directory to stay organized and reusable:
|
|
547
|
+
|
|
548
|
+
```
|
|
549
|
+
src/plugins/
|
|
550
|
+
├── gmail.ts # Gmail API integration
|
|
551
|
+
├── slack.ts # Slack notifications
|
|
552
|
+
├── database.ts # Database client
|
|
553
|
+
└── analytics.ts # Analytics tracking
|
|
440
554
|
```
|
|
441
555
|
|
|
442
|
-
Then in your `brain.ts
|
|
556
|
+
Then in your `src/brain.ts`:
|
|
443
557
|
|
|
444
558
|
```typescript
|
|
445
559
|
import { createBrain } from '@positronic/core';
|
|
446
|
-
import gmail from './
|
|
447
|
-
import slack from './
|
|
448
|
-
import database from './
|
|
449
|
-
import analytics from './
|
|
560
|
+
import { gmail } from './plugins/gmail.js';
|
|
561
|
+
import { slack } from './plugins/slack.js';
|
|
562
|
+
import { database } from './plugins/database.js';
|
|
563
|
+
import { analytics } from './plugins/analytics.js';
|
|
450
564
|
|
|
451
565
|
export const brain = createBrain({
|
|
452
|
-
|
|
453
|
-
gmail,
|
|
454
|
-
slack,
|
|
455
|
-
database,
|
|
456
|
-
analytics
|
|
457
|
-
}
|
|
566
|
+
plugins: [gmail, slack, database, analytics],
|
|
458
567
|
});
|
|
459
568
|
```
|
|
460
569
|
|
|
461
|
-
|
|
570
|
+
Each plugin is defined with `definePlugin` from `@positronic/core`. See `/docs/plugin-guide.md` for how to create plugins with typed config, tools, and adapters.
|
|
571
|
+
|
|
572
|
+
This keeps your plugin implementations separate from your brain logic and makes them easier to test and maintain.
|
|
462
573
|
|
|
463
574
|
## Rate Limiting with bottleneck
|
|
464
575
|
|
|
@@ -495,7 +606,7 @@ const slow = bottleneck({ rpd: 1000 }); // 1000 per day
|
|
|
495
606
|
Create one limiter per API and wrap all calls through it:
|
|
496
607
|
|
|
497
608
|
```typescript
|
|
498
|
-
// services/github.ts
|
|
609
|
+
// src/services/github.ts
|
|
499
610
|
import { bottleneck } from '../utils/bottleneck.js';
|
|
500
611
|
|
|
501
612
|
const limit = bottleneck({ rps: 10 });
|
|
@@ -555,7 +666,7 @@ const alertSchema = z.object({
|
|
|
555
666
|
});
|
|
556
667
|
|
|
557
668
|
const alertBrain = brain('Alert System')
|
|
558
|
-
.
|
|
669
|
+
.withOptions(alertSchema)
|
|
559
670
|
.step('Check Threshold', ({ state, options }) => ({
|
|
560
671
|
...state,
|
|
561
672
|
shouldAlert: state.errorCount > parseInt(options.alertThreshold)
|
|
@@ -585,9 +696,9 @@ This project uses ES modules (ESM). **Always include the `.js` extension in your
|
|
|
585
696
|
|
|
586
697
|
```typescript
|
|
587
698
|
// ✅ CORRECT - Include .js extension
|
|
588
|
-
import { brain } from '../brain.js'; // From a file in brains/
|
|
589
|
-
import { analyzeData } from '../utils/analyzer.js';
|
|
590
|
-
import gmail from '../services/gmail.js';
|
|
699
|
+
import { brain } from '../brain.js'; // From a file in src/brains/ (brain.ts is at src/brain.ts)
|
|
700
|
+
import { analyzeData } from '../utils/analyzer.js'; // From src/brains/ to src/utils/
|
|
701
|
+
import gmail from '../services/gmail.js'; // From src/brains/ to src/services/
|
|
591
702
|
|
|
592
703
|
// ❌ WRONG - Missing .js extension
|
|
593
704
|
import { brain } from '../brain';
|
|
@@ -615,7 +726,7 @@ Start by following the brain testing guide (`/docs/brain-testing-guide.md`) and
|
|
|
615
726
|
// tests/my-new-brain.test.ts
|
|
616
727
|
import { describe, it, expect } from '@jest/globals';
|
|
617
728
|
import { createMockClient, runBrainTest } from './test-utils.js';
|
|
618
|
-
import myNewBrain from '../brains/my-new-brain.js';
|
|
729
|
+
import myNewBrain from '../src/brains/my-new-brain.js';
|
|
619
730
|
|
|
620
731
|
describe('MyNewBrain', () => {
|
|
621
732
|
it('should process data and return expected result', async () => {
|
|
@@ -678,7 +789,7 @@ Build the brain one step at a time, testing as you go. **Actually run the brain
|
|
|
678
789
|
|
|
679
790
|
```bash
|
|
680
791
|
# 1. Create the brain with just the first step
|
|
681
|
-
# Write minimal implementation in brains/my-new-brain.ts
|
|
792
|
+
# Write minimal implementation in src/brains/my-new-brain.ts
|
|
682
793
|
|
|
683
794
|
# 2. Run the brain to test the first step
|
|
684
795
|
px brain run my-new-brain
|
|
@@ -739,34 +850,23 @@ export default feedbackBrain;
|
|
|
739
850
|
// Step 3: Run and check logs, see it doesn't analyze yet
|
|
740
851
|
// Step 4: Add sentiment analysis step
|
|
741
852
|
.prompt('Analyze sentiment', {
|
|
742
|
-
|
|
853
|
+
message: ({ state: { feedback } }) =>
|
|
743
854
|
<%= '\`Analyze the sentiment of this feedback: "${feedback}"\`' %>,
|
|
744
|
-
outputSchema: {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}),
|
|
749
|
-
name: 'sentimentAnalysis' as const
|
|
750
|
-
}
|
|
855
|
+
outputSchema: z.object({
|
|
856
|
+
sentiment: z.enum(['positive', 'neutral', 'negative']),
|
|
857
|
+
score: z.number().min(0).max(1)
|
|
858
|
+
}),
|
|
751
859
|
})
|
|
752
860
|
|
|
753
861
|
// Step 5: Run again, check logs, test still fails (no response)
|
|
754
862
|
// Step 6: Add response generation
|
|
755
863
|
.prompt('Generate response', {
|
|
756
|
-
|
|
757
|
-
<%= '\`Generate a brief response to this ${
|
|
758
|
-
outputSchema: {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
name: 'responseData' as const
|
|
763
|
-
}
|
|
764
|
-
})
|
|
765
|
-
.step('Format output', ({ state }) => ({
|
|
766
|
-
...state,
|
|
767
|
-
sentiment: state.sentimentAnalysis.sentiment,
|
|
768
|
-
response: state.responseData.response
|
|
769
|
-
}));
|
|
864
|
+
message: ({ state: { sentiment, feedback } }) =>
|
|
865
|
+
<%= '\`Generate a brief response to this ${sentiment} feedback: "${feedback}"\`' %>,
|
|
866
|
+
outputSchema: z.object({
|
|
867
|
+
response: z.string()
|
|
868
|
+
}),
|
|
869
|
+
});
|
|
770
870
|
|
|
771
871
|
// Step 7: Run test - it should pass now!
|
|
772
872
|
```
|