@positronic/template-new-project 0.0.77 → 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 +157 -95
- 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,7 @@ 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', { ... })`.
|
|
300
321
|
|
|
301
322
|
### Naming convention for filter/map parameters
|
|
302
323
|
|
|
@@ -314,27 +335,35 @@ state.transcripts.filter((match, extraction) => extraction.hasTranscript)
|
|
|
314
335
|
|
|
315
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.
|
|
316
337
|
|
|
317
|
-
### Nested brain state
|
|
338
|
+
### Nested brain state spreading
|
|
318
339
|
|
|
319
|
-
When a `.brain()` step runs an inner brain
|
|
340
|
+
When a `.brain()` step runs an inner brain, the inner brain's final state is spread directly onto the outer state:
|
|
320
341
|
|
|
321
342
|
```typescript
|
|
322
|
-
|
|
323
|
-
.brain(
|
|
324
|
-
'Search and validate',
|
|
325
|
-
searchAndValidate,
|
|
326
|
-
({ brainState }) => brainState as { matches: Match[] },
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
// ✅ DO THIS - destructure to select and let inference work
|
|
330
|
-
.brain(
|
|
331
|
-
'Search and validate',
|
|
332
|
-
searchAndValidate,
|
|
333
|
-
({ brainState: { matches } }) => ({ matches }),
|
|
334
|
-
)
|
|
343
|
+
.brain('Search and validate', searchAndValidate)
|
|
335
344
|
```
|
|
336
345
|
|
|
337
|
-
|
|
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
|
+
```
|
|
338
367
|
|
|
339
368
|
## Brain DSL Type Inference
|
|
340
369
|
|
|
@@ -366,6 +395,30 @@ brain('example')
|
|
|
366
395
|
|
|
367
396
|
The type inference flows through the entire chain, making the code cleaner and more maintainable.
|
|
368
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
|
+
|
|
369
422
|
## Error Handling in Brains
|
|
370
423
|
|
|
371
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.
|
|
@@ -413,13 +466,9 @@ brain('validation-example')
|
|
|
413
466
|
|
|
414
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.
|
|
415
468
|
|
|
416
|
-
##
|
|
469
|
+
## Page Steps for Form Generation
|
|
417
470
|
|
|
418
|
-
When you need to collect user input, use the `.
|
|
419
|
-
1. `.ui()` generates the page
|
|
420
|
-
2. Next step gets `page.url` and `page.webhook`
|
|
421
|
-
3. Notify users, then use `.wait()` with `page.webhook`
|
|
422
|
-
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.
|
|
423
472
|
|
|
424
473
|
```typescript
|
|
425
474
|
import { z } from 'zod';
|
|
@@ -429,74 +478,98 @@ brain('feedback-collector')
|
|
|
429
478
|
...state,
|
|
430
479
|
userName: 'John',
|
|
431
480
|
}))
|
|
432
|
-
// Generate the form
|
|
433
|
-
.
|
|
434
|
-
|
|
481
|
+
// Generate the form, onCreated, auto-suspend, auto-merge response
|
|
482
|
+
.page('Collect Feedback', ({ state, slack }) => ({
|
|
483
|
+
prompt: <%= '\`' %>
|
|
435
484
|
Create a feedback form for <%= '${state.userName}' %>:
|
|
436
485
|
- Rating (1-5)
|
|
437
486
|
- Comments textarea
|
|
438
487
|
- Submit button
|
|
439
488
|
<%= '\`' %>,
|
|
440
|
-
|
|
489
|
+
formSchema: z.object({
|
|
441
490
|
rating: z.number().min(1).max(5),
|
|
442
491
|
comments: z.string(),
|
|
443
492
|
}),
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
})
|
|
450
|
-
// Wait for form submission
|
|
451
|
-
.wait('Wait for submission', ({ page }) => page.webhook)
|
|
452
|
-
// Form data comes through response (not page)
|
|
453
|
-
.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 }) => ({
|
|
454
499
|
...state,
|
|
455
|
-
|
|
456
|
-
|
|
500
|
+
// state.rating and state.comments are typed
|
|
501
|
+
processed: true,
|
|
457
502
|
}));
|
|
458
503
|
```
|
|
459
504
|
|
|
460
505
|
Key points:
|
|
506
|
+
- `page` is available inside the `onCreated` callback, not in a separate step
|
|
461
507
|
- `page.url` - where to send users
|
|
462
|
-
-
|
|
463
|
-
-
|
|
464
|
-
- 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`
|
|
465
511
|
|
|
466
|
-
See `/docs/brain-dsl-guide.md` for more
|
|
512
|
+
See `/docs/brain-dsl-guide.md` for more page step examples.
|
|
467
513
|
|
|
468
|
-
|
|
514
|
+
### Use `.page()` for Data Display, Not `generatePage` in Loops
|
|
469
515
|
|
|
470
|
-
When
|
|
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.
|
|
517
|
+
|
|
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
|
+
}))
|
|
471
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
|
+
}))
|
|
472
540
|
```
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
478
554
|
```
|
|
479
555
|
|
|
480
|
-
Then in your `brain.ts
|
|
556
|
+
Then in your `src/brain.ts`:
|
|
481
557
|
|
|
482
558
|
```typescript
|
|
483
559
|
import { createBrain } from '@positronic/core';
|
|
484
|
-
import gmail from './
|
|
485
|
-
import slack from './
|
|
486
|
-
import database from './
|
|
487
|
-
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';
|
|
488
564
|
|
|
489
565
|
export const brain = createBrain({
|
|
490
|
-
|
|
491
|
-
gmail,
|
|
492
|
-
slack,
|
|
493
|
-
database,
|
|
494
|
-
analytics
|
|
495
|
-
}
|
|
566
|
+
plugins: [gmail, slack, database, analytics],
|
|
496
567
|
});
|
|
497
568
|
```
|
|
498
569
|
|
|
499
|
-
|
|
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.
|
|
500
573
|
|
|
501
574
|
## Rate Limiting with bottleneck
|
|
502
575
|
|
|
@@ -533,7 +606,7 @@ const slow = bottleneck({ rpd: 1000 }); // 1000 per day
|
|
|
533
606
|
Create one limiter per API and wrap all calls through it:
|
|
534
607
|
|
|
535
608
|
```typescript
|
|
536
|
-
// services/github.ts
|
|
609
|
+
// src/services/github.ts
|
|
537
610
|
import { bottleneck } from '../utils/bottleneck.js';
|
|
538
611
|
|
|
539
612
|
const limit = bottleneck({ rps: 10 });
|
|
@@ -593,7 +666,7 @@ const alertSchema = z.object({
|
|
|
593
666
|
});
|
|
594
667
|
|
|
595
668
|
const alertBrain = brain('Alert System')
|
|
596
|
-
.
|
|
669
|
+
.withOptions(alertSchema)
|
|
597
670
|
.step('Check Threshold', ({ state, options }) => ({
|
|
598
671
|
...state,
|
|
599
672
|
shouldAlert: state.errorCount > parseInt(options.alertThreshold)
|
|
@@ -623,9 +696,9 @@ This project uses ES modules (ESM). **Always include the `.js` extension in your
|
|
|
623
696
|
|
|
624
697
|
```typescript
|
|
625
698
|
// ✅ CORRECT - Include .js extension
|
|
626
|
-
import { brain } from '../brain.js'; // From a file in brains/
|
|
627
|
-
import { analyzeData } from '../utils/analyzer.js';
|
|
628
|
-
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/
|
|
629
702
|
|
|
630
703
|
// ❌ WRONG - Missing .js extension
|
|
631
704
|
import { brain } from '../brain';
|
|
@@ -653,7 +726,7 @@ Start by following the brain testing guide (`/docs/brain-testing-guide.md`) and
|
|
|
653
726
|
// tests/my-new-brain.test.ts
|
|
654
727
|
import { describe, it, expect } from '@jest/globals';
|
|
655
728
|
import { createMockClient, runBrainTest } from './test-utils.js';
|
|
656
|
-
import myNewBrain from '../brains/my-new-brain.js';
|
|
729
|
+
import myNewBrain from '../src/brains/my-new-brain.js';
|
|
657
730
|
|
|
658
731
|
describe('MyNewBrain', () => {
|
|
659
732
|
it('should process data and return expected result', async () => {
|
|
@@ -716,7 +789,7 @@ Build the brain one step at a time, testing as you go. **Actually run the brain
|
|
|
716
789
|
|
|
717
790
|
```bash
|
|
718
791
|
# 1. Create the brain with just the first step
|
|
719
|
-
# Write minimal implementation in brains/my-new-brain.ts
|
|
792
|
+
# Write minimal implementation in src/brains/my-new-brain.ts
|
|
720
793
|
|
|
721
794
|
# 2. Run the brain to test the first step
|
|
722
795
|
px brain run my-new-brain
|
|
@@ -777,34 +850,23 @@ export default feedbackBrain;
|
|
|
777
850
|
// Step 3: Run and check logs, see it doesn't analyze yet
|
|
778
851
|
// Step 4: Add sentiment analysis step
|
|
779
852
|
.prompt('Analyze sentiment', {
|
|
780
|
-
|
|
853
|
+
message: ({ state: { feedback } }) =>
|
|
781
854
|
<%= '\`Analyze the sentiment of this feedback: "${feedback}"\`' %>,
|
|
782
|
-
outputSchema: {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}),
|
|
787
|
-
name: 'sentimentAnalysis' as const
|
|
788
|
-
}
|
|
855
|
+
outputSchema: z.object({
|
|
856
|
+
sentiment: z.enum(['positive', 'neutral', 'negative']),
|
|
857
|
+
score: z.number().min(0).max(1)
|
|
858
|
+
}),
|
|
789
859
|
})
|
|
790
860
|
|
|
791
861
|
// Step 5: Run again, check logs, test still fails (no response)
|
|
792
862
|
// Step 6: Add response generation
|
|
793
863
|
.prompt('Generate response', {
|
|
794
|
-
|
|
795
|
-
<%= '\`Generate a brief response to this ${
|
|
796
|
-
outputSchema: {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
name: 'responseData' as const
|
|
801
|
-
}
|
|
802
|
-
})
|
|
803
|
-
.step('Format output', ({ state }) => ({
|
|
804
|
-
...state,
|
|
805
|
-
sentiment: state.sentimentAnalysis.sentiment,
|
|
806
|
-
response: state.responseData.response
|
|
807
|
-
}));
|
|
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
|
+
});
|
|
808
870
|
|
|
809
871
|
// Step 7: Run test - it should pass now!
|
|
810
872
|
```
|