@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 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.76';
57
- let cloudflareVersion = '^0.0.76';
58
- let clientVercelVersion = '^0.0.76';
59
- let genUIComponentsVersion = '^0.0.76';
56
+ let coreVersion = '^0.0.78';
57
+ let cloudflareVersion = '^0.0.78';
58
+ let clientVercelVersion = '^0.0.78';
59
+ let genUIComponentsVersion = '^0.0.78';
60
60
 
61
61
  // Map backend selection to package names
62
62
  const backendPackageMap = {
@@ -153,6 +153,7 @@ module.exports = {
153
153
  if (!ctx.answers.claudemd) {
154
154
  ctx.files = ctx.files.filter(file => file.path !== 'CLAUDE.md');
155
155
  }
156
+
156
157
  },
157
158
  complete: async ctx => {
158
159
  // Display getting started message
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@positronic/template-new-project",
3
- "version": "0.0.76",
3
+ "version": "0.0.78",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pre-compiles brain files from src/brains/ to .positronic/brains/.
3
+ *
4
+ * For .tsx files, an esbuild plugin preserves JSX text whitespace by wrapping
5
+ * JSXText nodes in expression containers before the JSX transform runs.
6
+ * For .ts files, esbuild simply transpiles TypeScript to JavaScript.
7
+ *
8
+ * The compiled output lives in .positronic/brains/ and resolves its relative
9
+ * imports (../brain.js, ../services/...) through symlinks that mirror src/.
10
+ */
11
+ import * as esbuild from 'esbuild';
12
+ import ts from 'typescript';
13
+ import { readdirSync, promises as fs } from 'fs';
14
+ import { join } from 'path';
15
+
16
+ // Esbuild plugin that preserves whitespace in JSX text nodes.
17
+ // Uses TypeScript's parser to find JSXText nodes and wrap them in
18
+ // expression containers ({`text`}) before esbuild's JSX transform runs.
19
+ function jsxTextPlugin() {
20
+ return {
21
+ name: 'jsx-text-preserver',
22
+ setup(build) {
23
+ build.onLoad({ filter: /\.tsx$/ }, async (args) => {
24
+ const source = await fs.readFile(args.path, 'utf8');
25
+ const sourceFile = ts.createSourceFile(
26
+ args.path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX
27
+ );
28
+
29
+ const replacements = [];
30
+ function visit(node) {
31
+ if (node.kind === ts.SyntaxKind.JsxText) {
32
+ const rawText = node.getText(sourceFile);
33
+ const escaped = rawText
34
+ .replace(/\\/g, '\\\\')
35
+ .replace(/`/g, '\\`')
36
+ .replace(/\$/g, '\\$');
37
+ replacements.push({
38
+ start: node.getStart(sourceFile),
39
+ end: node.getEnd(),
40
+ text: `{\`<%= '${escaped}' %>\`}`,
41
+ });
42
+ }
43
+ ts.forEachChild(node, visit);
44
+ }
45
+ visit(sourceFile);
46
+
47
+ if (replacements.length === 0) return { contents: source, loader: 'tsx' };
48
+
49
+ let result = source;
50
+ for (const r of replacements.reverse()) {
51
+ result = result.slice(0, r.start) + r.text + result.slice(r.end);
52
+ }
53
+ return { contents: result, loader: 'tsx' };
54
+ });
55
+ },
56
+ };
57
+ }
58
+
59
+ // Find all .ts and .tsx files recursively
60
+ function findBrainFiles(dir) {
61
+ const results = [];
62
+ let entries;
63
+ try {
64
+ entries = readdirSync(dir, { withFileTypes: true });
65
+ } catch {
66
+ return results;
67
+ }
68
+ for (const entry of entries) {
69
+ if (entry.name.startsWith('_')) continue;
70
+ const fullPath = join(dir, entry.name);
71
+ if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
72
+ results.push(fullPath);
73
+ } else if (entry.isDirectory()) {
74
+ results.push(...findBrainFiles(fullPath));
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+
80
+ const brainFiles = findBrainFiles('../src/brains');
81
+
82
+ if (brainFiles.length > 0) {
83
+ await esbuild.build({
84
+ entryPoints: brainFiles,
85
+ outdir: 'brains',
86
+ format: 'esm',
87
+ jsx: 'automatic',
88
+ jsxImportSource: '@positronic/core',
89
+ plugins: [jsxTextPlugin()],
90
+ bundle: false,
91
+ allowOverwrite: true,
92
+ });
93
+ }
@@ -7,7 +7,7 @@
7
7
  * When you add custom components to components/index.ts, they will automatically
8
8
  * be included in the bundle.
9
9
  */
10
- import { components } from '../components/index.js';
10
+ import { components } from './components/index.js';
11
11
 
12
12
  // Extract the React component from each UIComponent and expose to window
13
13
  const PositronicComponents: Record<string, React.ComponentType<any>> = {};
@@ -10,21 +10,26 @@ import {
10
10
  AuthDO,
11
11
  PositronicManifest,
12
12
  } from "@positronic/cloudflare";
13
+ import { collectPluginWebhooks } from "@positronic/core";
13
14
  // Import the generated manifests - NOTE the .js extension for runtime compatibility
14
15
  // @ts-expect-error - _manifest.js is generated during template processing
15
16
  import { manifest as brainManifest } from "./_manifest.js";
16
17
  // @ts-expect-error - _webhookManifest.js is generated during template processing
17
18
  import { webhookManifest } from "./_webhookManifest.js";
18
19
  import { runner } from "./runner.js";
20
+ import { brain as brainFactory } from "../brain.js";
19
21
 
20
22
  // Configure the manifest to use the statically generated list
21
23
  const manifest = new PositronicManifest({
22
24
  manifest: brainManifest,
23
25
  });
24
26
 
27
+ // Merge file-based webhooks with plugin-declared webhooks
28
+ const pluginWebhooks = collectPluginWebhooks(brainFactory.plugins);
29
+
25
30
  setManifest(manifest);
26
31
  setBrainRunner(runner);
27
- setWebhookManifest(webhookManifest);
32
+ setWebhookManifest({ ...webhookManifest, ...pluginWebhooks });
28
33
 
29
34
  // Define Env type based on wrangler.jsonc bindings
30
35
  interface Env {
@@ -6,6 +6,10 @@
6
6
  "compatibility_flags": ["nodejs_compat", "nodejs_compat_populate_process_env"],
7
7
  "workers_dev": true,
8
8
  "preview_urls": true,
9
+ "build": {
10
+ "command": "node build-brains.mjs",
11
+ "watch_dir": "../src/brains"
12
+ },
9
13
  "limits": {
10
14
  "cpu_ms": 300000
11
15
  },
@@ -8,13 +8,16 @@ This is a Positronic project - an AI-powered framework for building and running
8
8
 
9
9
  ## Project Structure
10
10
 
11
- - **`/brains`** - AI workflow definitions using the Brain DSL
12
- - **`/webhooks`** - Webhook definitions for external integrations (auto-discovered)
11
+ - **`/src`** - Application source code
12
+ - **`/src/brain.ts`** - Project brain wrapper (custom `brain` function)
13
+ - **`/src/brains`** - AI workflow definitions using the Brain DSL
14
+ - **`/src/runner.ts`** - The main entry point for running brains locally
15
+ - **`/src/utils`** - Shared utilities (e.g., `bottleneck` for rate limiting)
16
+ - **`/src/plugins`** - Plugin definitions for external integrations (see `/docs/plugin-guide.md`)
17
+ - **`/src/components`** - Reusable UI/prompt components
13
18
  - **`/resources`** - Files and documents that brains can access via the resource system
14
19
  - **`/tests`** - Test files for brains (kept separate to avoid deployment issues)
15
- - **`/utils`** - Shared utilities (e.g., `bottleneck` for rate limiting)
16
20
  - **`/docs`** - Documentation including brain testing guide
17
- - **`/runner.ts`** - The main entry point for running brains locally
18
21
  - **`/positronic.config.json`** - Project configuration
19
22
 
20
23
  ## Key Commands
@@ -40,6 +43,7 @@ The Brain DSL provides a fluent API for defining AI workflows:
40
43
 
41
44
  ```typescript
42
45
  // Import from the project brain wrapper (see positronic-guide.md)
46
+ // From a file in src/brains/, brain.ts is at src/brain.ts
43
47
  import { brain } from '../brain.js';
44
48
 
45
49
  const myBrain = brain('my-brain')
@@ -60,88 +64,183 @@ const myBrain = brain('my-brain')
60
64
  export default myBrain;
61
65
  ```
62
66
 
67
+ ### JSX Templates
68
+
69
+ Templates in `.prompt()`, `.page()`, and `.map()` steps can be written as JSX for better readability. Rename the brain file to `.tsx` and return JSX from the template function. See `/docs/brain-dsl-guide.md` for details.
70
+
63
71
  ## Resource System
64
72
 
65
73
  Resources are files that brains can access during execution. They're stored in the `/resources` directory and are automatically typed based on the manifest.
66
74
 
67
75
  ## Webhooks
68
76
 
69
- Webhooks allow brains to pause execution and wait for external events (like form submissions, API callbacks, or user approvals). Webhooks are auto-discovered from the `/webhooks` directory.
77
+ Webhooks allow brains to pause execution and wait for external events (like Slack replies or GitHub events). Webhooks live inside plugins, not in a separate directory.
78
+
79
+ ### Page Forms vs Service Webhooks
80
+
81
+ **Page forms** (user submits a form on a page generated by your brain) are handled automatically by `.page()` with `formSchema`. No webhook code needed — see "Custom HTML Pages" below and `/docs/brain-dsl-guide.md`.
70
82
 
71
- ### Creating a Webhook
83
+ **Service webhooks** (external services like Slack or GitHub calling back into your app) are declared on plugins. The plugin defines the webhook handler and exposes it for brains to use in `.wait()` steps or as tools in prompt loops.
72
84
 
73
- Create a file in the `/webhooks` directory with a default export:
85
+ ### Using Plugin Webhooks in Brains
86
+
87
+ Plugins that declare webhooks make them available on the step context:
74
88
 
75
89
  ```typescript
76
- // webhooks/approval.ts
77
- import { createWebhook } from '@positronic/core';
90
+ export default brain('feedback-loop')
91
+ .step('Post draft', async ({ state, slack }) => {
92
+ const result = await slack.sendMessage(channel, state.draft);
93
+ return { ...state, threadTs: result.ts };
94
+ })
95
+ .wait('Wait for feedback', ({ state, slack }) => slack.webhooks.slack(state.threadTs))
96
+ .handle('Apply feedback', ({ state, response }) => ({
97
+ ...state,
98
+ feedback: response.message.text,
99
+ }));
100
+ ```
101
+
102
+ The optional `timeout` parameter accepts durations like `'30m'`, `'1h'`, `'24h'`, `'7d'`, or a number in milliseconds. If the timeout elapses without a webhook response, the brain is cancelled. Without a timeout, the brain waits indefinitely.
103
+
104
+ ### Declaring Webhooks in Plugins
105
+
106
+ Webhooks are declared on the plugin definition using `createWebhook()` and returned from `create()` so brains can access them:
107
+
108
+ ```typescript
109
+ import { definePlugin, createWebhook } from '@positronic/core';
78
110
  import { z } from 'zod';
79
111
 
80
- const approvalWebhook = createWebhook(
81
- 'approval', // webhook name (should match filename)
82
- z.object({ // response schema - what the webhook returns to the brain
83
- approved: z.boolean(),
84
- reviewerNote: z.string().optional(),
85
- }),
112
+ const myWebhook = createWebhook(
113
+ 'my-service',
114
+ z.object({ message: z.string() }),
86
115
  async (request: Request) => {
87
- // Parse the incoming request and return identifier + response
88
116
  const body = await request.json();
89
117
  return {
90
118
  type: 'webhook',
91
- identifier: body.requestId, // matches the identifier used in waitFor
92
- response: {
93
- approved: body.approved,
94
- reviewerNote: body.note,
95
- },
119
+ identifier: body.threadId,
120
+ response: { message: body.text },
96
121
  };
97
122
  }
98
123
  );
99
124
 
100
- export default approvalWebhook;
125
+ export const myService = definePlugin({
126
+ name: 'myService',
127
+ webhooks: { myWebhook },
128
+ create: () => ({
129
+ webhooks: { myWebhook },
130
+ // ... other methods and tools
131
+ }),
132
+ });
101
133
  ```
102
134
 
103
- ### Using Webhooks in Brains
135
+ Plugin webhooks are automatically registered in the webhook manifest at startup.
136
+
137
+ ### Webhook Handler Return Types
138
+
139
+ The handler return type determines behavior:
140
+ - `{ type: 'webhook', identifier, response }` — resumes a waiting brain
141
+ - `{ type: 'trigger', response }` — starts a new brain run (requires `triggers` config)
142
+ - `{ type: 'ignore' }` — acknowledges receipt, takes no action
143
+ - `{ type: 'verification', challenge }` — handles webhook verification challenges
144
+
145
+ ### Custom HTML Pages
146
+
147
+ For pages with forms, use `.page()` with the `html` property instead of `prompt`. The framework handles CSRF tokens, webhook registration, suspension, and form data merging automatically:
148
+
149
+ ```tsx
150
+ import { Form } from '@positronic/core';
151
+
152
+ .page('Review items', ({ state }) => ({
153
+ html: (
154
+ <Form>
155
+ {state.items.map(item => (
156
+ <label>
157
+ <input type="checkbox" name="selectedIds" value={item.id} />
158
+ {item.name}
159
+ </label>
160
+ ))}
161
+ <button type="submit">Confirm</button>
162
+ </Form>
163
+ ),
164
+ formSchema: z.object({ selectedIds: z.array(z.string()) }),
165
+ onCreated: async (page) => {
166
+ await ntfy.send('Review ready', page.url);
167
+ },
168
+ }))
169
+ .step('Process', ({ state }) => {
170
+ // state.selectedIds comes from the form submission
171
+ return { ...state, confirmed: true };
172
+ })
173
+ ```
104
174
 
105
- Import the webhook and use `.wait()` to pause execution:
175
+ **Key details:**
176
+
177
+ - `Form` is imported from `@positronic/core` — it's a Symbol-based built-in component (like `Fragment`, `File`, `Resource`). The framework injects the form action URL into it during rendering.
178
+ - The `html` property accepts JSX directly, a string, or a function component.
179
+ - The page title comes from the step title. The framework wraps the html body in a full HTML document automatically.
180
+
181
+ ### Extracting Page JSX into Separate Files
182
+
183
+ For complex pages, extract the JSX into a separate `.tsx` file. Don't annotate the return type — let TypeScript infer it from the JSX:
184
+
185
+ ```tsx
186
+ // brains/my-brain/pages/review-page.tsx
187
+ import { Form } from '@positronic/core';
188
+ export function ReviewPage({ items }: { items: { id: string; name: string }[] }) {
189
+ return (
190
+ <Form>
191
+ {items.map(item => (
192
+ <label>
193
+ <input type="checkbox" name="selectedIds" value={item.id} />
194
+ {item.name}
195
+ </label>
196
+ ))}
197
+ <button type="submit">Confirm</button>
198
+ </Form>
199
+ );
200
+ }
201
+ ```
106
202
 
107
- ```typescript
108
- import { brain } from '../brain.js';
109
- import approvalWebhook from '../webhooks/approval.js';
203
+ Then use it in the brain:
110
204
 
111
- export default brain('approval-workflow')
112
- .step('Request approval', ({ state }) => ({
113
- ...state, status: 'pending',
114
- }))
115
- .wait('Wait for approval', ({ state }) => approvalWebhook(state.requestId), { timeout: '24h' })
116
- .step('Process approval', ({ state, response }) => ({
117
- ...state,
118
- status: response.approved ? 'approved' : 'rejected',
119
- reviewerNote: response.reviewerNote,
120
- }));
205
+ ```tsx
206
+ import { ReviewPage } from './pages/review-page.js';
207
+
208
+ .page('Review items', ({ state }) => ({
209
+ html: <ReviewPage items={state.items} />,
210
+ formSchema: z.object({ selectedIds: z.array(z.string()) }),
211
+ }))
121
212
  ```
122
213
 
123
- The optional `timeout` parameter accepts durations like `'30m'`, `'1h'`, `'24h'`, `'7d'`, or a number in milliseconds. If the timeout elapses without a webhook response, the brain is cancelled. Without a timeout, the brain waits indefinitely.
214
+ This works because the positronic JSX runtime handles function components it calls them with their props and renders the result. The `.tsx` file must use `jsxImportSource: "@positronic/core"` (inherited from the project tsconfig).
215
+
216
+ **Important:** Do NOT annotate the return type on page components. JSX produces `TemplateNode` (which is `JSX.Element`). Writing `: TemplateChild` or `: ReactNode` will cause type errors — these are different types. Let TypeScript infer the return type.
124
217
 
125
- ### CSRF Tokens for Pages with Forms
218
+ **HTML elements work in page JSX.** You can use `<div>`, `<input>`, `<label>`, `<table>`, `<style>`, etc. — any standard HTML element. This is specific to the `html` property on `.page()`. Prompt JSX (in `.prompt()`, `.map()`) only supports `<>` (Fragment), `<File>`, `<Resource>`, and function components — HTML elements will throw an error there.
126
219
 
127
- If your brain generates a custom HTML page with a form that submits to a webhook, you must include a CSRF token. Without a token, the server will reject the submission.
220
+ See `/docs/brain-dsl-guide.md` for full examples.
128
221
 
129
- 1. Generate a token with `generateFormToken()` from `@positronic/core`
130
- 2. Add `<input type="hidden" name="__positronic_token" value="<%= '${token}' %>">` to the form
131
- 3. Pass the token when creating the webhook registration: `myWebhook(identifier, token)`
222
+ ### No JavaScript in Custom Pages
132
223
 
133
- The `.ui()` step handles this automatically. See `/docs/brain-dsl-guide.md` for full examples.
224
+ Custom HTML pages are static inline `<script>` tags are blocked by a Content Security Policy (`script-src 'none'`). This prevents XSS when user data is interpolated into page content.
134
225
 
135
- ### How Auto-Discovery Works
226
+ **Form submission doesn't need JS.** Native HTML forms work. Unchecked checkboxes don't submit — only checked ones do. The framework's `parseFormData` handles duplicate field names as arrays automatically.
227
+
228
+ **For interactive UI (tabs, toggles, show/hide), use `<details>`/`<summary>` instead of JavaScript:**
229
+
230
+ ```tsx
231
+ {categories.map(cat => (
232
+ <details open={cat === firstCategory}>
233
+ <summary>{cat.label} ({cat.count})</summary>
234
+ <ThreadList emails={cat.emails} />
235
+ </details>
236
+ ))}
237
+ ```
136
238
 
137
- - Place webhook files in `/webhooks` directory
138
- - Each file must have a default export using `createWebhook()`
139
- - The dev server generates `_webhookManifest.ts` automatically
140
- - Webhook name comes from the filename (e.g., `approval.ts` → `'approval'`)
239
+ This gives collapsible sections that work without JS. Style with CSS for a polished look.
141
240
 
142
241
  ## Development Workflow
143
242
 
144
- 1. Define your brain in `/brains`
243
+ 1. Define your brain in `/src/brains`
145
244
  2. Add any required resources to `/resources`
146
245
  3. Run `px brain run <brain-name>` to test locally
147
246
  4. Deploy using backend-specific commands