@positronic/template-new-project 0.0.75 → 0.0.77
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 +4 -4
- package/package.json +1 -1
- package/template/CLAUDE.md +1 -0
- package/template/brain.ts +4 -0
- package/template/docs/brain-dsl-guide.md +167 -29
- package/template/docs/tips-for-agents.md +298 -7
- package/template/utils/bottleneck.ts +26 -0
package/index.js
CHANGED
|
@@ -53,10 +53,10 @@ module.exports = {
|
|
|
53
53
|
],
|
|
54
54
|
setup: async ctx => {
|
|
55
55
|
const devRootPath = process.env.POSITRONIC_LOCAL_PATH;
|
|
56
|
-
let coreVersion = '^0.0.
|
|
57
|
-
let cloudflareVersion = '^0.0.
|
|
58
|
-
let clientVercelVersion = '^0.0.
|
|
59
|
-
let genUIComponentsVersion = '^0.0.
|
|
56
|
+
let coreVersion = '^0.0.77';
|
|
57
|
+
let cloudflareVersion = '^0.0.77';
|
|
58
|
+
let clientVercelVersion = '^0.0.77';
|
|
59
|
+
let genUIComponentsVersion = '^0.0.77';
|
|
60
60
|
|
|
61
61
|
// Map backend selection to package names
|
|
62
62
|
const backendPackageMap = {
|
package/package.json
CHANGED
package/template/CLAUDE.md
CHANGED
|
@@ -12,6 +12,7 @@ This is a Positronic project - an AI-powered framework for building and running
|
|
|
12
12
|
- **`/webhooks`** - Webhook definitions for external integrations (auto-discovered)
|
|
13
13
|
- **`/resources`** - Files and documents that brains can access via the resource system
|
|
14
14
|
- **`/tests`** - Test files for brains (kept separate to avoid deployment issues)
|
|
15
|
+
- **`/utils`** - Shared utilities (e.g., `bottleneck` for rate limiting)
|
|
15
16
|
- **`/docs`** - Documentation including brain testing guide
|
|
16
17
|
- **`/runner.ts`** - The main entry point for running brains locally
|
|
17
18
|
- **`/positronic.config.json`** - Project configuration
|
package/template/brain.ts
CHANGED
|
@@ -18,6 +18,10 @@ import { components } from './components/index.js';
|
|
|
18
18
|
* - consoleLog: Log messages for debugging
|
|
19
19
|
* - done: Complete the agent and return a result
|
|
20
20
|
*
|
|
21
|
+
* Tool configuration:
|
|
22
|
+
* - `withTools({ ... })` — replaces the default tools entirely
|
|
23
|
+
* - `withExtraTools({ ... })` — adds tools alongside the defaults
|
|
24
|
+
*
|
|
21
25
|
* To add services (e.g., Slack, Gmail, database clients):
|
|
22
26
|
*
|
|
23
27
|
* ```typescript
|
|
@@ -81,7 +81,7 @@ brain('AI Education Assistant')
|
|
|
81
81
|
context: 'We are creating an educational example',
|
|
82
82
|
}))
|
|
83
83
|
.prompt('Generate explanation', {
|
|
84
|
-
template: ({ topic, context }) =>
|
|
84
|
+
template: ({ state: { topic, context } }) =>
|
|
85
85
|
`<%= '${context}' %>. Please provide a brief, beginner-friendly explanation of <%= '${topic}' %>.`,
|
|
86
86
|
outputSchema: {
|
|
87
87
|
schema: z.object({
|
|
@@ -104,7 +104,7 @@ brain('AI Education Assistant')
|
|
|
104
104
|
.prompt(
|
|
105
105
|
'Generate follow-up questions',
|
|
106
106
|
{
|
|
107
|
-
template: ({ formattedOutput }) =>
|
|
107
|
+
template: ({ state: { formattedOutput } }) =>
|
|
108
108
|
`Based on this explanation about <%= '${formattedOutput.topic}' %>: "<%= '${formattedOutput.explanation}' %>"
|
|
109
109
|
|
|
110
110
|
Generate 3 thoughtful follow-up questions that a student might ask.`,
|
|
@@ -147,7 +147,7 @@ const smartModel = createAnthropicClient({ model: 'claude-sonnet-4-5-20250929' }
|
|
|
147
147
|
|
|
148
148
|
brain('Multi-Model Brain')
|
|
149
149
|
.prompt('Quick summary', {
|
|
150
|
-
template: ({ document }) => `Summarize this briefly: <%= '${document}' %>`,
|
|
150
|
+
template: ({ state: { document } }) => `Summarize this briefly: <%= '${document}' %>`,
|
|
151
151
|
outputSchema: {
|
|
152
152
|
schema: z.object({ summary: z.string() }),
|
|
153
153
|
name: 'quickSummary' as const,
|
|
@@ -155,7 +155,7 @@ brain('Multi-Model Brain')
|
|
|
155
155
|
client: fastModel, // Use a fast, cheap model for summarization
|
|
156
156
|
})
|
|
157
157
|
.prompt('Deep analysis', {
|
|
158
|
-
template: ({ quickSummary }) =>
|
|
158
|
+
template: ({ state: { quickSummary } }) =>
|
|
159
159
|
`Analyze the implications of this summary: <%= '${quickSummary.summary}' %>`,
|
|
160
160
|
outputSchema: {
|
|
161
161
|
schema: z.object({
|
|
@@ -419,7 +419,7 @@ Services are destructured alongside other parameters in:
|
|
|
419
419
|
2. **Prompt Reduce Functions**:
|
|
420
420
|
```typescript
|
|
421
421
|
.prompt('Generate', {
|
|
422
|
-
template: (state) => 'Generate something',
|
|
422
|
+
template: ({ state }) => 'Generate something',
|
|
423
423
|
outputSchema: { schema, name: 'result' as const }
|
|
424
424
|
}, async ({ state, response, logger, database }) => {
|
|
425
425
|
logger.info('Saving AI response');
|
|
@@ -477,7 +477,7 @@ const analysisBrain = brain('Data Analysis')
|
|
|
477
477
|
return { ...state, data };
|
|
478
478
|
})
|
|
479
479
|
.prompt('Analyze Data', {
|
|
480
|
-
template: ({ data }) => `Analyze this data: <%= '${JSON.stringify(data)}' %>`,
|
|
480
|
+
template: ({ state: { data } }) => `Analyze this data: <%= '${JSON.stringify(data)}' %>`,
|
|
481
481
|
outputSchema: {
|
|
482
482
|
schema: z.object({
|
|
483
483
|
insights: z.array(z.string()),
|
|
@@ -630,7 +630,7 @@ const brainWithComponents = brain('Custom UI Brain')
|
|
|
630
630
|
}
|
|
631
631
|
})
|
|
632
632
|
.ui('Dashboard', {
|
|
633
|
-
template: (state) => `
|
|
633
|
+
template: ({ state }) => `
|
|
634
634
|
Create a dashboard using CustomCard components to display:
|
|
635
635
|
- User name: <%= '${state.userName}' %>
|
|
636
636
|
- Account status: <%= '${state.status}' %>
|
|
@@ -773,6 +773,39 @@ export const brain = createBrain({
|
|
|
773
773
|
|
|
774
774
|
All brains created with this factory will have access to the configured services, tools, components, and store.
|
|
775
775
|
|
|
776
|
+
#### Typing Initial State and Options
|
|
777
|
+
|
|
778
|
+
By default, the first `.step()` establishes the state type and inference flows from there. But when a brain receives its initial state from outside — via `initialState` in `.run()`, from the CLI, or from a parent brain — the first step's `state` parameter is untyped.
|
|
779
|
+
|
|
780
|
+
You can provide type parameters to `brain()` to type the initial state and options:
|
|
781
|
+
|
|
782
|
+
```typescript
|
|
783
|
+
// brain<TOptions, TState>(title)
|
|
784
|
+
// Both parameters are optional and default to {} and object respectively.
|
|
785
|
+
|
|
786
|
+
// Type just the initial state (pass {} for options)
|
|
787
|
+
const myBrain = brain<{}, { userId: string; email: string }>('process-user')
|
|
788
|
+
.step('Greet', ({ state }) => {
|
|
789
|
+
// state.userId and state.email are correctly typed
|
|
790
|
+
return { ...state, greeting: 'Hello ' + state.email };
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Type both options and initial state
|
|
794
|
+
const myBrain = brain<{ verbose: boolean }, { count: number }>('counter')
|
|
795
|
+
.step('Process', ({ state, options }) => {
|
|
796
|
+
if (options.verbose) console.log('Count:', state.count);
|
|
797
|
+
return { ...state, doubled: state.count * 2 };
|
|
798
|
+
});
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
This is useful in several situations:
|
|
802
|
+
|
|
803
|
+
- **Brains run with `initialState`**: When calling `.run({ initialState: { ... } })` or passing initial state from the CLI
|
|
804
|
+
- **Sub-brains**: When a parent brain provides initial state via `.brain()` or iterate's `initialState` option
|
|
805
|
+
- **Any brain where the first step receives rather than creates state**
|
|
806
|
+
|
|
807
|
+
Existing `brain('title')` calls without type parameters continue to work unchanged.
|
|
808
|
+
|
|
776
809
|
## Running Brains
|
|
777
810
|
|
|
778
811
|
### Basic Execution
|
|
@@ -898,7 +931,7 @@ Resources are also available in prompt templates:
|
|
|
898
931
|
|
|
899
932
|
```typescript
|
|
900
933
|
brain('Template Example').prompt('Generate Content', {
|
|
901
|
-
template: async (state, resources) => {
|
|
934
|
+
template: async ({ state, resources }) => {
|
|
902
935
|
const template = await resources.prompts.customerSupport.load();
|
|
903
936
|
return template.replace('{{issue}}', state.issue);
|
|
904
937
|
},
|
|
@@ -992,7 +1025,7 @@ interface FilterPromptState {
|
|
|
992
1025
|
|
|
993
1026
|
// Export the prompt configuration
|
|
994
1027
|
export const aiFilterPrompt = {
|
|
995
|
-
template: async (state: FilterPromptState, resources: Resources) => {
|
|
1028
|
+
template: async ({ state, resources }: { state: FilterPromptState, resources: Resources }) => {
|
|
996
1029
|
// Load a prompt template from resources
|
|
997
1030
|
const template = await resources.prompts.hnFilter.load();
|
|
998
1031
|
|
|
@@ -1042,12 +1075,16 @@ Extract prompts to separate files when:
|
|
|
1042
1075
|
- The prompt might be reused in other brains
|
|
1043
1076
|
- You want to test the prompt logic separately
|
|
1044
1077
|
|
|
1045
|
-
##
|
|
1078
|
+
## Iterating Over Items
|
|
1046
1079
|
|
|
1047
|
-
When you need to run the same prompt over multiple items, use
|
|
1080
|
+
When you need to run the same prompt, brain, or agent over multiple items, use the `over` option.
|
|
1081
|
+
|
|
1082
|
+
### Prompt Iterate
|
|
1083
|
+
|
|
1084
|
+
Run the same prompt once per item in a list:
|
|
1048
1085
|
|
|
1049
1086
|
```typescript
|
|
1050
|
-
brain('
|
|
1087
|
+
brain('Item Processor')
|
|
1051
1088
|
.step('Initialize', () => ({
|
|
1052
1089
|
items: [
|
|
1053
1090
|
{ id: 1, title: 'First item' },
|
|
@@ -1056,37 +1093,138 @@ brain('Batch Processor')
|
|
|
1056
1093
|
]
|
|
1057
1094
|
}))
|
|
1058
1095
|
.prompt('Summarize Items', {
|
|
1059
|
-
template: (item) => `Summarize this item: <%= '${item.title}' %>`,
|
|
1096
|
+
template: ({ item }) => `Summarize this item: <%= '${item.title}' %>`,
|
|
1060
1097
|
outputSchema: {
|
|
1061
1098
|
schema: z.object({ summary: z.string() }),
|
|
1062
1099
|
name: 'summaries' as const
|
|
1063
1100
|
}
|
|
1064
1101
|
}, {
|
|
1065
|
-
over: (state) => state.items,
|
|
1066
|
-
|
|
1067
|
-
error: (item, error) => ({ summary: 'Failed to summarize' }) // Fallback on error
|
|
1102
|
+
over: ({ state }) => state.items,
|
|
1103
|
+
error: (item, error) => ({ summary: 'Failed to summarize' })
|
|
1068
1104
|
})
|
|
1069
1105
|
.step('Process Results', ({ state }) => ({
|
|
1070
1106
|
...state,
|
|
1071
|
-
// summaries is
|
|
1072
|
-
processedSummaries: state.summaries.map((
|
|
1107
|
+
// summaries is an IterateResult — use .values, .items, .entries, .filter(), .map()
|
|
1108
|
+
processedSummaries: state.summaries.map((item, response) => ({
|
|
1073
1109
|
id: item.id,
|
|
1074
1110
|
summary: response.summary
|
|
1075
1111
|
}))
|
|
1076
1112
|
}));
|
|
1077
1113
|
```
|
|
1078
1114
|
|
|
1079
|
-
|
|
1115
|
+
Prompt iterate also supports per-step `client` overrides (see Prompt Steps above), so you can use a different model for processing.
|
|
1080
1116
|
|
|
1081
|
-
|
|
1082
|
-
- `concurrency: number` - Maximum number of items processed in parallel (default: 10)
|
|
1083
|
-
- `error: (item, error) => Response` - Fallback function when a request fails
|
|
1117
|
+
### Brain Iterate
|
|
1084
1118
|
|
|
1085
|
-
|
|
1119
|
+
Run a nested brain once per item:
|
|
1120
|
+
|
|
1121
|
+
```typescript
|
|
1122
|
+
const processBrain = brain('Process Item')
|
|
1123
|
+
.step('Transform', ({ state }) => ({
|
|
1124
|
+
...state,
|
|
1125
|
+
result: state.value * 2,
|
|
1126
|
+
}));
|
|
1127
|
+
|
|
1128
|
+
brain('Process All Items')
|
|
1129
|
+
.step('Initialize', () => ({
|
|
1130
|
+
items: [{ value: 1 }, { value: 2 }, { value: 3 }]
|
|
1131
|
+
}))
|
|
1132
|
+
.brain('Process Each', processBrain, {
|
|
1133
|
+
over: ({ state }) => state.items,
|
|
1134
|
+
initialState: (item) => ({ value: item.value }),
|
|
1135
|
+
outputKey: 'results' as const,
|
|
1136
|
+
error: (item, error) => ({ value: item.value, failed: true }),
|
|
1137
|
+
})
|
|
1138
|
+
.step('Use Results', ({ state }) => ({
|
|
1139
|
+
...state,
|
|
1140
|
+
// results is an IterateResult — use .values to get just the results
|
|
1141
|
+
totals: state.results.values.map(result => result.result),
|
|
1142
|
+
}));
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
### Agent Iterate
|
|
1146
|
+
|
|
1147
|
+
Run an agent config once per item. The `configFn` receives the item as its first argument:
|
|
1148
|
+
|
|
1149
|
+
```typescript
|
|
1150
|
+
brain('Research Topics')
|
|
1151
|
+
.step('Initialize', () => ({
|
|
1152
|
+
topics: [{ name: 'AI' }, { name: 'Robotics' }]
|
|
1153
|
+
}))
|
|
1154
|
+
.brain('Research Each', (item, { state, tools }) => ({
|
|
1155
|
+
system: 'You are a research assistant.',
|
|
1156
|
+
prompt: `Research this topic: <%= '${item.name}' %>`,
|
|
1157
|
+
tools: {
|
|
1158
|
+
search: tools.search,
|
|
1159
|
+
},
|
|
1160
|
+
outputSchema: {
|
|
1161
|
+
schema: z.object({ summary: z.string() }),
|
|
1162
|
+
name: 'research' as const,
|
|
1163
|
+
},
|
|
1164
|
+
}), {
|
|
1165
|
+
over: ({ state }) => state.topics,
|
|
1166
|
+
outputKey: 'results' as const,
|
|
1167
|
+
})
|
|
1168
|
+
.step('Use Results', ({ state }) => ({
|
|
1169
|
+
...state,
|
|
1170
|
+
// results is an IterateResult — use .values to get just the results
|
|
1171
|
+
summaries: state.results.values.map(result => result.summary),
|
|
1172
|
+
}));
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
### Iterate Options
|
|
1176
|
+
|
|
1177
|
+
All iterate variants share these options:
|
|
1178
|
+
|
|
1179
|
+
- `over: (context) => T[] | Promise<T[]>` - Function returning the array to iterate over. Receives the full step context (`{ state, options, client, resources, services, ... }`) — the same context object that step actions receive. Most commonly you'll destructure just `{ state }`, but you can access options, services, or any other context field. Can be async.
|
|
1180
|
+
- `error: (item, error) => Result | null` - Fallback when an item fails. Return `null` to skip the item entirely.
|
|
1181
|
+
|
|
1182
|
+
Brain and agent iterate also require:
|
|
1183
|
+
|
|
1184
|
+
- `outputKey: string` - Key under which results are stored in state (use `as const` for type inference)
|
|
1185
|
+
|
|
1186
|
+
Brain iterate additionally requires:
|
|
1187
|
+
|
|
1188
|
+
- `initialState: (item, outerState) => State` - Function to create the inner brain's initial state from each item
|
|
1189
|
+
|
|
1190
|
+
#### Accessing options and services in `over`
|
|
1191
|
+
|
|
1192
|
+
Since `over` receives the full step context, you can use options or services to determine which items to iterate over:
|
|
1193
|
+
|
|
1194
|
+
```typescript
|
|
1195
|
+
brain('Dynamic Processor')
|
|
1196
|
+
.withOptionsSchema(z.object({ category: z.string() }))
|
|
1197
|
+
.step('Load items', () => ({
|
|
1198
|
+
items: [
|
|
1199
|
+
{ id: 1, category: 'a' },
|
|
1200
|
+
{ id: 2, category: 'b' },
|
|
1201
|
+
{ id: 3, category: 'a' },
|
|
1202
|
+
]
|
|
1203
|
+
}))
|
|
1204
|
+
.prompt('Process', {
|
|
1205
|
+
template: ({ item }) => `Process item <%= '${item.id}' %>`,
|
|
1206
|
+
outputSchema: {
|
|
1207
|
+
schema: z.object({ result: z.string() }),
|
|
1208
|
+
name: 'results' as const,
|
|
1209
|
+
},
|
|
1210
|
+
}, {
|
|
1211
|
+
over: ({ state, options }) => state.items.filter(i => i.category === options.category),
|
|
1212
|
+
})
|
|
1213
|
+
```
|
|
1086
1214
|
|
|
1087
1215
|
### Result Format
|
|
1088
1216
|
|
|
1089
|
-
|
|
1217
|
+
By default, results are stored as an `IterateResult` — a collection that wraps `[item, result]` pairs and provides a richer API than raw tuples:
|
|
1218
|
+
|
|
1219
|
+
- **`.items`** — array of all input items
|
|
1220
|
+
- **`.values`** — array of all results
|
|
1221
|
+
- **`.entries`** — array of `[item, result]` tuples
|
|
1222
|
+
- **`.length`** — number of results
|
|
1223
|
+
- **`.filter((item, result) => boolean)`** — returns a new `IterateResult` with only matching pairs
|
|
1224
|
+
- **`.map((item, result) => value)`** — maps over both item and result, returns a plain array
|
|
1225
|
+
- **`for...of`** — iterates as `[item, result]` tuples (backward compatible with destructuring)
|
|
1226
|
+
|
|
1227
|
+
For prompts, the key comes from `outputSchema.name`. For brain and agent iterate, it comes from `outputKey`.
|
|
1090
1228
|
|
|
1091
1229
|
## Agent Steps
|
|
1092
1230
|
|
|
@@ -1396,7 +1534,7 @@ brain('Feedback Collector')
|
|
|
1396
1534
|
}))
|
|
1397
1535
|
// Generate the form
|
|
1398
1536
|
.ui('Collect Feedback', {
|
|
1399
|
-
template: (state) => `
|
|
1537
|
+
template: ({ state }) => `
|
|
1400
1538
|
Create a feedback form for <%= '${state.userName}' %>.
|
|
1401
1539
|
Include fields for rating (1-5) and comments.
|
|
1402
1540
|
`,
|
|
@@ -1442,7 +1580,7 @@ Be specific about layout and content:
|
|
|
1442
1580
|
|
|
1443
1581
|
```typescript
|
|
1444
1582
|
.ui('Contact Form', {
|
|
1445
|
-
template: (state) => `
|
|
1583
|
+
template: ({ state }) => `
|
|
1446
1584
|
Create a contact form with:
|
|
1447
1585
|
- Header: "Get in Touch"
|
|
1448
1586
|
- Name field (required)
|
|
@@ -1466,7 +1604,7 @@ Use `{{path}}` syntax to bind props to runtime data:
|
|
|
1466
1604
|
|
|
1467
1605
|
```typescript
|
|
1468
1606
|
.ui('Order Summary', {
|
|
1469
|
-
template: (state) => `
|
|
1607
|
+
template: ({ state }) => `
|
|
1470
1608
|
Create an order summary showing:
|
|
1471
1609
|
- List of items from {{cart.items}}
|
|
1472
1610
|
- Total: {{cart.total}}
|
|
@@ -1513,7 +1651,7 @@ brain('User Onboarding')
|
|
|
1513
1651
|
|
|
1514
1652
|
// Step 2: Preferences
|
|
1515
1653
|
.ui('Preferences', {
|
|
1516
|
-
template: (state) => `
|
|
1654
|
+
template: ({ state }) => `
|
|
1517
1655
|
Create preferences form for <%= '${state.userData.firstName}' %>:
|
|
1518
1656
|
- Newsletter subscription checkbox
|
|
1519
1657
|
- Contact preference (email/phone/sms)
|
|
@@ -1570,7 +1708,7 @@ const completeBrain = brain({
|
|
|
1570
1708
|
return { startTime: Date.now() };
|
|
1571
1709
|
})
|
|
1572
1710
|
.prompt('Generate Plan', {
|
|
1573
|
-
template: async (state, resources) => {
|
|
1711
|
+
template: async ({ state, resources }) => {
|
|
1574
1712
|
// Load a template from resources
|
|
1575
1713
|
const template = await resources.templates.projectPlan.load();
|
|
1576
1714
|
return template.replace('{{context}}', 'software project');
|
|
@@ -6,6 +6,40 @@ This document contains helpful tips and patterns for AI agents working with Posi
|
|
|
6
6
|
|
|
7
7
|
Run `npm run typecheck` frequently as you make changes to ensure your TypeScript code compiles correctly. This will catch type errors early and help maintain code quality.
|
|
8
8
|
|
|
9
|
+
## Prefer Type Inference
|
|
10
|
+
|
|
11
|
+
Never add explicit type annotations unless `npm run typecheck` tells you to. TypeScript's inference is very strong — especially within the Brain DSL chain — and explicit types add noise without value.
|
|
12
|
+
|
|
13
|
+
Start by writing code with no annotations. If `typecheck` fails, add the minimum annotation or cast needed to fix it.
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// ❌ DON'T DO THIS - explicit types on callback parameters
|
|
17
|
+
.filter(([_, result]: [any, any]) => result !== null)
|
|
18
|
+
.map((pr: any) => pr.author)
|
|
19
|
+
.map((n: string) => n.trim())
|
|
20
|
+
error: (thread: any, error: any) => { ... }
|
|
21
|
+
|
|
22
|
+
// ✅ DO THIS - let inference work
|
|
23
|
+
.filter(([_, result]) => result !== null)
|
|
24
|
+
.map(pr => pr.author)
|
|
25
|
+
.map(n => n.trim())
|
|
26
|
+
error: (thread, error) => { ... }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This also applies to variable declarations and function parameters:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// ❌ DON'T DO THIS
|
|
33
|
+
const names: string[] = options.notify.split(',');
|
|
34
|
+
template: ({ state }: any) => { ... }
|
|
35
|
+
|
|
36
|
+
// ✅ DO THIS
|
|
37
|
+
const names = options.notify.split(',');
|
|
38
|
+
template: ({ state }) => { ... }
|
|
39
|
+
```
|
|
40
|
+
|
|
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.
|
|
42
|
+
|
|
9
43
|
## Running the Development Server
|
|
10
44
|
|
|
11
45
|
When you need to run a development server, use the `--log-file` option to capture server output. **Important**: Always place the server log file in the `/tmp` directory so it gets cleaned up automatically by the operating system.
|
|
@@ -125,6 +159,183 @@ Key rules:
|
|
|
125
159
|
- Optional title as second argument: `.guard(predicate, 'Check condition')`
|
|
126
160
|
- See `/docs/brain-dsl-guide.md` for more details
|
|
127
161
|
|
|
162
|
+
**Guards vs exceptions**: Use guards for conditions that are an expected part of the brain's flow — like "no audio URL was found" after a discovery step. Guards are documented in the DSL and show up when viewing the brain's steps. Reserve `throw` for truly unexpected errors. If a missing value is a normal possible outcome of a previous step, handle it with a guard, not an exception.
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// ❌ DON'T DO THIS - throwing for an expected outcome
|
|
166
|
+
.step('Transcribe', async ({ state }) => {
|
|
167
|
+
if (!state.discovery.audioUrl) {
|
|
168
|
+
throw new Error('No audio URL found');
|
|
169
|
+
}
|
|
170
|
+
const transcript = await whisper.transcribe(state.discovery.audioUrl);
|
|
171
|
+
return { ...state, transcript };
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ✅ DO THIS - guard for expected flow, keep the step focused
|
|
175
|
+
.guard(({ state: { discovery } }) => !!discovery.audioUrl, 'Has audio URL')
|
|
176
|
+
.step('Transcribe', async ({ state: { discovery } }) => {
|
|
177
|
+
const transcript = await whisper.transcribe(discovery.audioUrl!);
|
|
178
|
+
return { ...state, transcript };
|
|
179
|
+
})
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Destructure State in Steps
|
|
183
|
+
|
|
184
|
+
Always destructure properties off of `state` rather than accessing them through `state.property`. This applies to steps, prompt templates, brain callbacks, and guards — anywhere state is accessed.
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// ❌ DON'T DO THIS - accessing properties through state
|
|
188
|
+
.brain('Find data', ({ state }) => ({
|
|
189
|
+
prompt: `Process <%= '${state.user.name}' %> from <%= '${state.user.email}' %>`,
|
|
190
|
+
}))
|
|
191
|
+
|
|
192
|
+
// ✅ DO THIS - destructure in the parameter when state itself isn't needed
|
|
193
|
+
.brain('Find data', ({ state: { user } }) => ({
|
|
194
|
+
prompt: `Process <%= '${user.name}' %> from <%= '${user.email}' %>`,
|
|
195
|
+
}))
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The same applies to prompt templates:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// ❌ DON'T DO THIS
|
|
202
|
+
template: ({ state }) => `Hello <%= '${state.user.name}' %>, your order <%= '${state.order.id}' %> is ready.`,
|
|
203
|
+
|
|
204
|
+
// ✅ DO THIS
|
|
205
|
+
template: ({ state: { user, order } }) => `Hello <%= '${user.name}' %>, your order <%= '${order.id}' %> is ready.`,
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
When you still need `state` (e.g. for `...state` in the return value), destructure in the function body instead:
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
// ❌ DON'T DO THIS
|
|
212
|
+
.step('Format', ({ state }) => ({
|
|
213
|
+
...state,
|
|
214
|
+
summary: `<%= '${state.title}' %> by <%= '${state.author}' %>`,
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
// ✅ DO THIS - destructure in the body when you also need ...state
|
|
218
|
+
.step('Format', ({ state }) => {
|
|
219
|
+
const { title, author } = state;
|
|
220
|
+
return {
|
|
221
|
+
...state,
|
|
222
|
+
summary: `<%= '${title}' %> by <%= '${author}' %>`,
|
|
223
|
+
};
|
|
224
|
+
})
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## State Shape
|
|
228
|
+
|
|
229
|
+
### Each step should have one clear purpose, and add one thing to state
|
|
230
|
+
|
|
231
|
+
Don't let steps do multiple unrelated things. Each step should have a clear name that describes its single purpose, and it should add one key to state. If a step produces multiple data points, namespace them under a single key.
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// ❌ DON'T DO THIS - step does too much and adds multiple keys
|
|
235
|
+
.step('Process', async ({ state }) => ({
|
|
236
|
+
...state,
|
|
237
|
+
transcript: await transcribe(state.audioUrl),
|
|
238
|
+
episodeTitle: state.discovery.episodeTitle,
|
|
239
|
+
podcastName: state.podcast.source,
|
|
240
|
+
podcastUrl: state.podcast.url,
|
|
241
|
+
}))
|
|
242
|
+
|
|
243
|
+
// ✅ DO THIS - step has one purpose, adds one thing
|
|
244
|
+
.step('Transcribe', async ({ state }) => {
|
|
245
|
+
const { discovery } = state;
|
|
246
|
+
const transcript = await whisper.transcribe(discovery.audioUrl!);
|
|
247
|
+
return { ...state, transcript };
|
|
248
|
+
})
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Previous steps already namespace their results on state (e.g. `state.discovery`, `state.podcast`). Don't copy their fields to the top level — it duplicates data and makes it unclear which version is canonical.
|
|
252
|
+
|
|
253
|
+
### Reshape state at phase boundaries
|
|
254
|
+
|
|
255
|
+
As steps build up state, it can accumulate intermediate artifacts. At major phase transitions in a brain — like going from "gathering data" to "analyzing it" — reshape state to a clean form for the next phase. Return only what the next phase needs instead of spreading everything forward.
|
|
256
|
+
|
|
257
|
+
The smell to watch for: if you're reading a brain and can't quickly answer "what's the canonical version of X on state?" then state needs reshaping.
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
// After a data-gathering phase, clean up for analysis
|
|
261
|
+
.step('Prepare for analysis', ({ state }) => {
|
|
262
|
+
const { discovery, transcript, podcast } = state;
|
|
263
|
+
// Only carry forward what the analysis phase needs
|
|
264
|
+
return { podcast, discovery, transcript };
|
|
265
|
+
})
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Iterate Results
|
|
269
|
+
|
|
270
|
+
Iterate steps produce an `IterateResult` — use its properties and methods to access results cleanly:
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
// Access just the results
|
|
274
|
+
state.results.values.map(r => r.summary)
|
|
275
|
+
|
|
276
|
+
// Access just the input items
|
|
277
|
+
state.results.items
|
|
278
|
+
|
|
279
|
+
// Filter by both item and result
|
|
280
|
+
state.results.filter((item, r) => r.isImportant).items
|
|
281
|
+
|
|
282
|
+
// Map over both item and result
|
|
283
|
+
state.results.map((item, r) => ({ id: item.id, summary: r.summary }))
|
|
284
|
+
|
|
285
|
+
// Tuple destructuring still works (backward compatible)
|
|
286
|
+
for (const [item, result] of state.results) { ... }
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Use `.values` for simple extraction, `.filter()` for correlated filtering, and `.map()` when you need both item and result:
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
.step('Process', ({ state }) => ({
|
|
293
|
+
...state,
|
|
294
|
+
important: state.results.filter((item, r) => r.score > 0.8).items,
|
|
295
|
+
summaries: state.results.values.map(r => r.summary),
|
|
296
|
+
}))
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**Name the `outputKey` after the content.** If results contain analyses, use `outputKey: 'analyses' as const`, not `outputKey: 'processedItems' as const`.
|
|
300
|
+
|
|
301
|
+
### Naming convention for filter/map parameters
|
|
302
|
+
|
|
303
|
+
`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`:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// ❌ DON'T DO THIS - generic parameter names
|
|
307
|
+
state.validations.filter((item, r) => r.matches)
|
|
308
|
+
state.transcripts.filter((t) => t.hasTranscript) // WRONG: single param is the item, not the result
|
|
309
|
+
|
|
310
|
+
// ✅ DO THIS - descriptive names that reflect the data
|
|
311
|
+
state.validations.filter((crawledResult, validation) => validation.matches)
|
|
312
|
+
state.transcripts.filter((match, extraction) => extraction.hasTranscript)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
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
|
+
|
|
317
|
+
### Nested brain state mapping
|
|
318
|
+
|
|
319
|
+
When a `.brain()` step runs an inner brain and maps its state into the outer brain, **destructure to select the fields you need** instead of casting:
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// ❌ DON'T DO THIS - casting to force the type
|
|
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
|
+
)
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
The inner brain's state type is fully inferred from its definition. Destructuring picks the fields you want and TypeScript infers the outer state correctly — no casts needed. If the inner brain exports an interface for its output shape, import it for use in downstream steps (like prompt templates).
|
|
338
|
+
|
|
128
339
|
## Brain DSL Type Inference
|
|
129
340
|
|
|
130
341
|
The Brain DSL has very strong type inference capabilities. **Important**: You should NOT explicitly specify types on the state object as it flows through steps. The types are automatically inferred from the previous step.
|
|
@@ -220,7 +431,7 @@ brain('feedback-collector')
|
|
|
220
431
|
}))
|
|
221
432
|
// Generate the form
|
|
222
433
|
.ui('Collect Feedback', {
|
|
223
|
-
template: (state) => <%= '\`' %>
|
|
434
|
+
template: ({ state }) => <%= '\`' %>
|
|
224
435
|
Create a feedback form for <%= '${state.userName}' %>:
|
|
225
436
|
- Rating (1-5)
|
|
226
437
|
- Comments textarea
|
|
@@ -287,6 +498,86 @@ export const brain = createBrain({
|
|
|
287
498
|
|
|
288
499
|
This keeps your service implementations separate from your brain logic and makes them easier to test and maintain.
|
|
289
500
|
|
|
501
|
+
## Rate Limiting with bottleneck
|
|
502
|
+
|
|
503
|
+
Most external APIs have rate limits. The `utils/bottleneck.ts` utility creates a simple rate limiter you can wrap around any async call.
|
|
504
|
+
|
|
505
|
+
### Basic Usage
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
import { bottleneck } from '../utils/bottleneck.js';
|
|
509
|
+
|
|
510
|
+
// Create a limiter — exactly one rate unit is required
|
|
511
|
+
const limit = bottleneck({ rpm: 60 }); // 60 requests per minute
|
|
512
|
+
|
|
513
|
+
// Wrap any async call with the limiter
|
|
514
|
+
const result = await limit(() => api.fetchData(id));
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### Config Options
|
|
518
|
+
|
|
519
|
+
Pass exactly one of these (TypeScript enforces this):
|
|
520
|
+
|
|
521
|
+
- `rps` — requests per second
|
|
522
|
+
- `rpm` — requests per minute
|
|
523
|
+
- `rph` — requests per hour
|
|
524
|
+
- `rpd` — requests per day
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
const fast = bottleneck({ rps: 10 }); // 10 per second
|
|
528
|
+
const slow = bottleneck({ rpd: 1000 }); // 1000 per day
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### Wrapping a Service
|
|
532
|
+
|
|
533
|
+
Create one limiter per API and wrap all calls through it:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
// services/github.ts
|
|
537
|
+
import { bottleneck } from '../utils/bottleneck.js';
|
|
538
|
+
|
|
539
|
+
const limit = bottleneck({ rps: 10 });
|
|
540
|
+
|
|
541
|
+
async function getRepo(owner: string, repo: string) {
|
|
542
|
+
return limit(() =>
|
|
543
|
+
fetch('https://api.github.com/repos/' + owner + '/' + repo)
|
|
544
|
+
.then(r => r.json())
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function listIssues(owner: string, repo: string) {
|
|
549
|
+
return limit(() =>
|
|
550
|
+
fetch('https://api.github.com/repos/' + owner + '/' + repo + '/issues')
|
|
551
|
+
.then(r => r.json())
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export default { getRepo, listIssues };
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### Using with Iterate
|
|
559
|
+
|
|
560
|
+
When iterating over items, wrap the API call inside the step callback:
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
import { bottleneck } from '../utils/bottleneck.js';
|
|
564
|
+
|
|
565
|
+
const limit = bottleneck({ rpm: 60 });
|
|
566
|
+
|
|
567
|
+
brain('process-items')
|
|
568
|
+
.step('Init', ({ state }) => ({ items: state.items }))
|
|
569
|
+
.step('Fetch details', async ({ state }) => {
|
|
570
|
+
const details = await Promise.all(
|
|
571
|
+
state.items.map(item => limit(() => api.getDetail(item.id)))
|
|
572
|
+
);
|
|
573
|
+
return { ...state, details };
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### When Creating Services
|
|
578
|
+
|
|
579
|
+
When building a new service that wraps an external API, research the API's rate limits and add a bottleneck upfront. It's much easier to add rate limiting from the start than to debug 429 errors later.
|
|
580
|
+
|
|
290
581
|
## Brain Options Usage
|
|
291
582
|
|
|
292
583
|
When creating brains that need runtime configuration, use the options schema pattern:
|
|
@@ -309,14 +600,14 @@ const alertBrain = brain('Alert System')
|
|
|
309
600
|
}))
|
|
310
601
|
.step('Send Alerts', async ({ state, options, slack }) => {
|
|
311
602
|
if (!state.shouldAlert) return state;
|
|
312
|
-
|
|
603
|
+
|
|
313
604
|
await slack.post(options.slackChannel, state.message);
|
|
314
|
-
|
|
605
|
+
|
|
315
606
|
if (options.emailEnabled === 'true') {
|
|
316
607
|
// Note: CLI options come as strings
|
|
317
608
|
await email.send('admin@example.com', state.message);
|
|
318
609
|
}
|
|
319
|
-
|
|
610
|
+
|
|
320
611
|
return { ...state, alerted: true };
|
|
321
612
|
});
|
|
322
613
|
```
|
|
@@ -486,7 +777,7 @@ export default feedbackBrain;
|
|
|
486
777
|
// Step 3: Run and check logs, see it doesn't analyze yet
|
|
487
778
|
// Step 4: Add sentiment analysis step
|
|
488
779
|
.prompt('Analyze sentiment', {
|
|
489
|
-
template: ({ feedback }) =>
|
|
780
|
+
template: ({ state: { feedback } }) =>
|
|
490
781
|
<%= '\`Analyze the sentiment of this feedback: "${feedback}"\`' %>,
|
|
491
782
|
outputSchema: {
|
|
492
783
|
schema: z.object({
|
|
@@ -500,7 +791,7 @@ export default feedbackBrain;
|
|
|
500
791
|
// Step 5: Run again, check logs, test still fails (no response)
|
|
501
792
|
// Step 6: Add response generation
|
|
502
793
|
.prompt('Generate response', {
|
|
503
|
-
template: ({ sentimentAnalysis, feedback }) =>
|
|
794
|
+
template: ({ state: { sentimentAnalysis, feedback } }) =>
|
|
504
795
|
<%= '\`Generate a brief response to this ${sentimentAnalysis.sentiment} feedback: "${feedback}"\`' %>,
|
|
505
796
|
outputSchema: {
|
|
506
797
|
schema: z.object({
|
|
@@ -528,4 +819,4 @@ export default feedbackBrain;
|
|
|
528
819
|
- Let TypeScript infer types - don't add explicit type annotations
|
|
529
820
|
- Don't catch errors unless it's part of the workflow logic
|
|
530
821
|
- Run `npm run typecheck` frequently to catch type errors early
|
|
531
|
-
- Stop the server when done: `px server -k` (default server) or `kill $(cat .positronic-server.pid)`
|
|
822
|
+
- Stop the server when done: `px server -k` (default server) or `kill $(cat .positronic-server.pid)`
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type BottleneckConfig =
|
|
2
|
+
| { rps: number; rpm?: never; rph?: never; rpd?: never }
|
|
3
|
+
| { rpm: number; rps?: never; rph?: never; rpd?: never }
|
|
4
|
+
| { rph: number; rps?: never; rpm?: never; rpd?: never }
|
|
5
|
+
| { rpd: number; rps?: never; rpm?: never; rph?: never };
|
|
6
|
+
|
|
7
|
+
export function bottleneck(config: BottleneckConfig) {
|
|
8
|
+
const interval = configToInterval(config);
|
|
9
|
+
let next = 0;
|
|
10
|
+
|
|
11
|
+
return async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
const delay = Math.max(0, next - now);
|
|
14
|
+
next = Math.max(now, next) + interval;
|
|
15
|
+
if (delay > 0) await new Promise<void>((r) => setTimeout(r, delay));
|
|
16
|
+
return fn();
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function configToInterval(config: BottleneckConfig) {
|
|
21
|
+
if ('rps' in config && config.rps !== undefined) return 1000 / config.rps;
|
|
22
|
+
if ('rpm' in config && config.rpm !== undefined) return 60_000 / config.rpm;
|
|
23
|
+
if ('rph' in config && config.rph !== undefined) return 3_600_000 / config.rph;
|
|
24
|
+
if ('rpd' in config && config.rpd !== undefined) return 86_400_000 / config.rpd;
|
|
25
|
+
return 0;
|
|
26
|
+
}
|