@electric-ax/agents 0.2.1 → 0.2.3

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.
Files changed (49) hide show
  1. package/dist/entrypoint.js +5 -3
  2. package/dist/index.cjs +5 -3
  3. package/dist/index.js +5 -3
  4. package/docs/entities/agents/horton.md +89 -0
  5. package/docs/entities/agents/worker.md +102 -0
  6. package/docs/entities/patterns/blackboard.md +111 -0
  7. package/docs/entities/patterns/dispatcher.md +77 -0
  8. package/docs/entities/patterns/manager-worker.md +127 -0
  9. package/docs/entities/patterns/map-reduce.md +81 -0
  10. package/docs/entities/patterns/pipeline.md +101 -0
  11. package/docs/entities/patterns/reactive-observers.md +125 -0
  12. package/docs/examples/mega-draw.md +106 -0
  13. package/docs/examples/playground.md +46 -0
  14. package/docs/index.md +208 -0
  15. package/docs/quickstart.md +201 -0
  16. package/docs/reference/agent-config.md +82 -0
  17. package/docs/reference/agent-tool.md +58 -0
  18. package/docs/reference/built-in-collections.md +334 -0
  19. package/docs/reference/cli.md +238 -0
  20. package/docs/reference/entity-definition.md +57 -0
  21. package/docs/reference/entity-handle.md +63 -0
  22. package/docs/reference/entity-registry.md +73 -0
  23. package/docs/reference/handler-context.md +108 -0
  24. package/docs/reference/runtime-handler.md +136 -0
  25. package/docs/reference/shared-state-handle.md +74 -0
  26. package/docs/reference/state-collection-proxy.md +41 -0
  27. package/docs/reference/wake-event.md +132 -0
  28. package/docs/usage/app-setup.md +165 -0
  29. package/docs/usage/clients-and-react.md +191 -0
  30. package/docs/usage/configuring-the-agent.md +136 -0
  31. package/docs/usage/context-composition.md +204 -0
  32. package/docs/usage/defining-entities.md +181 -0
  33. package/docs/usage/defining-tools.md +229 -0
  34. package/docs/usage/embedded-builtins.md +180 -0
  35. package/docs/usage/managing-state.md +93 -0
  36. package/docs/usage/overview.md +284 -0
  37. package/docs/usage/programmatic-runtime-client.md +216 -0
  38. package/docs/usage/shared-state.md +169 -0
  39. package/docs/usage/spawning-and-coordinating.md +165 -0
  40. package/docs/usage/testing.md +76 -0
  41. package/docs/usage/waking-entities.md +148 -0
  42. package/docs/usage/writing-handlers.md +267 -0
  43. package/package.json +3 -2
  44. package/skills/quickstart/scaffold/package.json +17 -3
  45. package/skills/quickstart/scaffold/tsconfig.json +8 -3
  46. package/skills/quickstart/scaffold/vite.config.ts +21 -0
  47. package/skills/quickstart/scaffold-ui/index.html +12 -0
  48. package/skills/quickstart/scaffold-ui/main.tsx +440 -0
  49. package/skills/quickstart.md +209 -344
@@ -4,14 +4,28 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "start": "tsx server.ts",
7
- "dev": "tsx --watch server.ts"
7
+ "dev": "tsx --watch server.ts",
8
+ "dev:server": "tsx --watch server.ts",
9
+ "dev:ui": "vite",
10
+ "dev:all": "npm run dev:server & npm run dev:ui"
8
11
  },
9
12
  "dependencies": {
10
13
  "@electric-ax/agents-runtime": "latest",
11
- "@sinclair/typebox": "^0.34.49"
14
+ "@radix-ui/react-icons": "^1.3.0",
15
+ "@radix-ui/themes": "^3.3.0",
16
+ "@sinclair/typebox": "^0.34.49",
17
+ "@tanstack/db": "^0.6.0",
18
+ "@tanstack/react-db": "^0.1.78",
19
+ "react": "^19.2.4",
20
+ "react-dom": "^19.2.4",
21
+ "streamdown": "^2.5.0"
12
22
  },
13
23
  "devDependencies": {
24
+ "@types/react": "^19.2.14",
25
+ "@types/react-dom": "^19.2.3",
26
+ "@vitejs/plugin-react": "^5.2.0",
14
27
  "tsx": "^4.19.0",
15
- "typescript": "^5.7.0"
28
+ "typescript": "^5.7.0",
29
+ "vite": "^7.2.4"
16
30
  }
17
31
  }
@@ -3,13 +3,18 @@
3
3
  "target": "ES2022",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
6
7
  "strict": true,
7
8
  "esModuleInterop": true,
8
9
  "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
9
11
  "resolveJsonModule": true,
10
- "allowImportingTsExtensions": false,
11
- "noEmit": true
12
+ "isolatedModules": true,
13
+ "noEmit": true,
14
+ "paths": {
15
+ "@tanstack/db": ["./node_modules/@tanstack/db"]
16
+ }
12
17
  },
13
- "include": ["**/*.ts"],
18
+ "include": ["**/*.ts", "**/*.tsx"],
14
19
  "exclude": ["node_modules"]
15
20
  }
@@ -0,0 +1,21 @@
1
+ import path from 'node:path'
2
+ import { defineConfig } from 'vite'
3
+ import react from '@vitejs/plugin-react'
4
+
5
+ export default defineConfig({
6
+ root: `ui`,
7
+ plugins: [react()],
8
+ resolve: {
9
+ alias: {
10
+ '@tanstack/db': path.resolve(
11
+ import.meta.dirname,
12
+ `node_modules/@tanstack/db`
13
+ ),
14
+ },
15
+ },
16
+ server: {
17
+ port: 5175,
18
+ proxy: { '/api': `http://localhost:3000` },
19
+ },
20
+ build: { outDir: `../dist`, emptyOutDir: true },
21
+ })
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Perspectives Analyzer</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="./main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,440 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react'
10
+ import { createRoot } from 'react-dom/client'
11
+ import { createAgentsClient, entity } from '@electric-ax/agents-runtime'
12
+ import { useChat } from '@electric-ax/agents-runtime/react'
13
+ import {
14
+ Theme,
15
+ Box,
16
+ Flex,
17
+ Heading,
18
+ Text,
19
+ TextField,
20
+ Button,
21
+ Card,
22
+ IconButton,
23
+ } from '@radix-ui/themes'
24
+ import { SunIcon, MoonIcon, DesktopIcon } from '@radix-ui/react-icons'
25
+ import { Streamdown } from 'streamdown'
26
+ import '@radix-ui/themes/styles.css'
27
+ import 'streamdown/styles.css'
28
+ import type { EntityStreamDB } from '@electric-ax/agents-runtime'
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Dark-mode hook (inlined – single-file scaffold)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const DARK_MODE_STORAGE_KEY = `electric-perspectives.dark-mode`
35
+
36
+ type ThemePreference = `light` | `dark` | `system`
37
+
38
+ type DarkModeContextValue = {
39
+ darkMode: boolean
40
+ preference: ThemePreference
41
+ cyclePreference: () => void
42
+ }
43
+
44
+ const DarkModeContext = createContext<DarkModeContextValue | null>(null)
45
+
46
+ function readInitialPreference(): ThemePreference {
47
+ if (typeof window === `undefined`) return `system`
48
+ const stored = window.localStorage.getItem(DARK_MODE_STORAGE_KEY)
49
+ if (stored === `true` || stored === `dark`) return `dark`
50
+ if (stored === `false` || stored === `light`) return `light`
51
+ return `system`
52
+ }
53
+
54
+ function systemPrefersDark(): boolean {
55
+ if (typeof window === `undefined`) return false
56
+ return window.matchMedia?.(`(prefers-color-scheme: dark)`).matches ?? false
57
+ }
58
+
59
+ const cycleOrder: Record<ThemePreference, ThemePreference> = {
60
+ light: `dark`,
61
+ dark: `system`,
62
+ system: `light`,
63
+ }
64
+
65
+ function DarkModeProvider({
66
+ children,
67
+ }: {
68
+ children: React.ReactNode
69
+ }): React.ReactElement {
70
+ const [preference, setPreference] = useState<ThemePreference>(
71
+ readInitialPreference
72
+ )
73
+ const [systemDark, setSystemDark] = useState<boolean>(systemPrefersDark)
74
+
75
+ useEffect(() => {
76
+ if (typeof window === `undefined`) return
77
+ const mql = window.matchMedia(`(prefers-color-scheme: dark)`)
78
+ const onChange = (e: MediaQueryListEvent): void => setSystemDark(e.matches)
79
+ mql.addEventListener(`change`, onChange)
80
+ return () => mql.removeEventListener(`change`, onChange)
81
+ }, [])
82
+
83
+ const darkMode = preference === `system` ? systemDark : preference === `dark`
84
+
85
+ useEffect(() => {
86
+ document.documentElement.classList.toggle(`dark`, darkMode)
87
+ }, [darkMode])
88
+
89
+ const cyclePreference = useCallback(() => {
90
+ setPreference((current) => {
91
+ const next = cycleOrder[current]
92
+ window.localStorage.setItem(DARK_MODE_STORAGE_KEY, next)
93
+ return next
94
+ })
95
+ }, [])
96
+
97
+ const value = useMemo(
98
+ () => ({ darkMode, preference, cyclePreference }),
99
+ [darkMode, preference, cyclePreference]
100
+ )
101
+
102
+ return (
103
+ <DarkModeContext.Provider value={value}>
104
+ {children}
105
+ </DarkModeContext.Provider>
106
+ )
107
+ }
108
+
109
+ function useDarkModeContext(): DarkModeContextValue {
110
+ const value = useContext(DarkModeContext)
111
+ if (!value) {
112
+ throw new Error(`useDarkModeContext must be used inside DarkModeProvider`)
113
+ }
114
+ return value
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Theme toggle button
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function themeButtonIcon(preference: ThemePreference) {
122
+ if (preference === `light`) return <SunIcon />
123
+ if (preference === `dark`) return <MoonIcon />
124
+ return <DesktopIcon />
125
+ }
126
+
127
+ function themeButtonAriaLabel(preference: ThemePreference): string {
128
+ if (preference === `light`) return `Switch to dark mode`
129
+ if (preference === `dark`) return `Switch to system theme`
130
+ return `Switch to light mode`
131
+ }
132
+
133
+ function ThemeToggle() {
134
+ const { preference, cyclePreference } = useDarkModeContext()
135
+ return (
136
+ <IconButton
137
+ variant="ghost"
138
+ size="2"
139
+ color="gray"
140
+ onClick={cyclePreference}
141
+ aria-label={themeButtonAriaLabel(preference)}
142
+ >
143
+ {themeButtonIcon(preference)}
144
+ </IconButton>
145
+ )
146
+ }
147
+
148
+ const AGENTS_URL = `http://localhost:4437`
149
+
150
+ const AGENT_COLORS: Record<string, { bg: string; border: string }> = {
151
+ analyser: { bg: `var(--blue-3)`, border: `var(--blue-6)` },
152
+ optimist: { bg: `var(--green-3)`, border: `var(--green-6)` },
153
+ critic: { bg: `var(--red-3)`, border: `var(--red-6)` },
154
+ }
155
+
156
+ function useEntityDb(url: string | null, retryMs = 0) {
157
+ const [db, setDb] = useState<EntityStreamDB | null>(null)
158
+
159
+ useEffect(() => {
160
+ if (!url) {
161
+ setDb(null)
162
+ return
163
+ }
164
+ let cancelled = false
165
+ let observedDb: EntityStreamDB | null = null
166
+ let timer: ReturnType<typeof setTimeout> | null = null
167
+
168
+ const connect = () => {
169
+ const client = createAgentsClient({ baseUrl: AGENTS_URL })
170
+ client.observe(entity(url)).then(
171
+ (observed) => {
172
+ observedDb = observed as EntityStreamDB
173
+ if (cancelled) {
174
+ observedDb.close()
175
+ return
176
+ }
177
+ setDb(observedDb)
178
+ },
179
+ () => {
180
+ if (!cancelled && retryMs > 0) {
181
+ timer = setTimeout(connect, retryMs)
182
+ }
183
+ }
184
+ )
185
+ }
186
+ connect()
187
+
188
+ return () => {
189
+ cancelled = true
190
+ if (timer) clearTimeout(timer)
191
+ observedDb?.close()
192
+ }
193
+ }, [url, retryMs])
194
+
195
+ return db
196
+ }
197
+
198
+ interface AgentMessage {
199
+ agent: string
200
+ text: string
201
+ isStreaming: boolean
202
+ }
203
+
204
+ function useAgentMessages(
205
+ url: string | null,
206
+ agent: string,
207
+ retryMs = 0
208
+ ): AgentMessage[] {
209
+ const db = useEntityDb(url, retryMs)
210
+ const chat = useChat(db)
211
+
212
+ return chat.runs.flatMap((r, ri) =>
213
+ r.texts
214
+ .filter((t) => t.text.trim().length > 0)
215
+ .map((t, ti) => ({
216
+ agent,
217
+ text: t.text,
218
+ isStreaming:
219
+ chat.state === `working` &&
220
+ ri === chat.runs.length - 1 &&
221
+ ti === r.texts.length - 1,
222
+ }))
223
+ )
224
+ }
225
+
226
+ function MessageBubble({ msg }: { msg: AgentMessage }) {
227
+ const colors = AGENT_COLORS[msg.agent] ?? {
228
+ bg: `var(--gray-3)`,
229
+ border: `var(--gray-6)`,
230
+ }
231
+
232
+ return (
233
+ <Card
234
+ size="1"
235
+ style={{
236
+ background: colors.bg,
237
+ borderLeft: `3px solid ${colors.border}`,
238
+ }}
239
+ >
240
+ <Text size="1" weight="bold" style={{ textTransform: `capitalize` }}>
241
+ {msg.agent}
242
+ </Text>
243
+ <Box mt="1" style={{ fontSize: `var(--font-size-2)` }}>
244
+ <Streamdown isAnimating={msg.isStreaming} controls={false}>
245
+ {msg.text}
246
+ </Streamdown>
247
+ </Box>
248
+ </Card>
249
+ )
250
+ }
251
+
252
+ function urlsFromId(id: string) {
253
+ return {
254
+ entityUrl: `/perspectives/${id}`,
255
+ optimistUrl: `/worker/${id}-optimist`,
256
+ criticUrl: `/worker/${id}-critic`,
257
+ }
258
+ }
259
+
260
+ function getIdFromHash(): string | null {
261
+ const hash = window.location.hash.slice(1)
262
+ return hash || null
263
+ }
264
+
265
+ function App() {
266
+ const [question, setQuestion] = useState(``)
267
+ const [urls, setUrls] = useState<{
268
+ entityUrl: string
269
+ optimistUrl: string
270
+ criticUrl: string
271
+ } | null>(() => {
272
+ const id = getIdFromHash()
273
+ return id ? urlsFromId(id) : null
274
+ })
275
+ const [loading, setLoading] = useState(false)
276
+ const bottomRef = useRef<HTMLDivElement>(null)
277
+
278
+ const analyserMessages = useAgentMessages(urls?.entityUrl ?? null, `analyser`)
279
+ const optimistMessages = useAgentMessages(
280
+ urls?.optimistUrl ?? null,
281
+ `optimist`,
282
+ 2000
283
+ )
284
+ const criticMessages = useAgentMessages(
285
+ urls?.criticUrl ?? null,
286
+ `critic`,
287
+ 2000
288
+ )
289
+
290
+ const hasOptimist = optimistMessages.length > 0
291
+ const hasCritic = criticMessages.length > 0
292
+ const hasWorkerMessages = hasOptimist || hasCritic
293
+ const hasBothWorkers = hasOptimist && hasCritic
294
+ // First analyser message is the intro; the rest is synthesis after workers
295
+ const analyserIntro = analyserMessages.slice(0, 1)
296
+ const analyserSynthesis = hasWorkerMessages
297
+ ? analyserMessages.slice(1).filter((m) => m.text.trim() !== `waiting`)
298
+ : []
299
+
300
+ useEffect(() => {
301
+ const el = bottomRef.current?.parentElement
302
+ if (!el) return
303
+ const observer = new MutationObserver(() => {
304
+ window.scrollTo({ top: document.body.scrollHeight, behavior: `smooth` })
305
+ })
306
+ observer.observe(el, {
307
+ childList: true,
308
+ subtree: true,
309
+ characterData: true,
310
+ })
311
+ return () => observer.disconnect()
312
+ }, [urls])
313
+
314
+ const handleAnalyze = async () => {
315
+ if (!question.trim()) return
316
+ setLoading(true)
317
+ try {
318
+ const res = await fetch(`/api/analyze`, {
319
+ method: `POST`,
320
+ headers: { 'Content-Type': `application/json` },
321
+ body: JSON.stringify({ question }),
322
+ })
323
+ const data = (await res.json()) as {
324
+ entityUrl: string
325
+ optimistUrl: string
326
+ criticUrl: string
327
+ }
328
+ const id = data.entityUrl.split(`/`).pop()!
329
+ window.location.hash = id
330
+ setUrls(data)
331
+ } catch (err) {
332
+ console.error(`Analyze failed:`, err)
333
+ } finally {
334
+ setLoading(false)
335
+ }
336
+ }
337
+
338
+ return (
339
+ <>
340
+ <style>{`@keyframes blink { 50% { opacity: 0; } }`}</style>
341
+ <Box maxWidth="900px" mx="auto" p="5">
342
+ <Flex justify="between" align="center" mb="1">
343
+ <Heading size="6">Perspectives Analyzer</Heading>
344
+ <ThemeToggle />
345
+ </Flex>
346
+ <Text size="2" color="gray" mb="5" as="p">
347
+ Ask a question and get two perspectives — an optimist and a critic —
348
+ then a balanced analysis.
349
+ </Text>
350
+
351
+ <Flex gap="2" mb="5">
352
+ <Box flexGrow="1">
353
+ <TextField.Root
354
+ size="3"
355
+ placeholder="Enter a question to analyze..."
356
+ value={question}
357
+ onChange={(e) => setQuestion(e.target.value)}
358
+ onKeyDown={(e) => e.key === `Enter` && handleAnalyze()}
359
+ />
360
+ </Box>
361
+ <Button
362
+ size="3"
363
+ onClick={handleAnalyze}
364
+ disabled={loading || !question.trim()}
365
+ >
366
+ {loading ? `Analyzing...` : `Analyze`}
367
+ </Button>
368
+ </Flex>
369
+
370
+ <Flex direction="column" gap="3">
371
+ {urls && analyserIntro.length === 0 && (
372
+ <Text color="gray" size="2">
373
+ Waiting for agents to respond...
374
+ </Text>
375
+ )}
376
+
377
+ {/* Analyser intro — full width */}
378
+ {analyserIntro.map((msg, i) => (
379
+ <MessageBubble
380
+ key={`analyser-intro-${i}`}
381
+ msg={hasWorkerMessages ? { ...msg, isStreaming: false } : msg}
382
+ />
383
+ ))}
384
+
385
+ {/* Workers — single column until both respond, then side-by-side */}
386
+ {hasWorkerMessages && !hasBothWorkers && (
387
+ <Flex direction="column" gap="3">
388
+ {optimistMessages.map((msg, i) => (
389
+ <MessageBubble key={`optimist-${i}`} msg={msg} />
390
+ ))}
391
+ {criticMessages.map((msg, i) => (
392
+ <MessageBubble key={`critic-${i}`} msg={msg} />
393
+ ))}
394
+ </Flex>
395
+ )}
396
+ {hasBothWorkers && (
397
+ <Flex gap="3">
398
+ <Flex direction="column" gap="3" style={{ flex: 1, minWidth: 0 }}>
399
+ {optimistMessages.map((msg, i) => (
400
+ <MessageBubble key={`optimist-${i}`} msg={msg} />
401
+ ))}
402
+ </Flex>
403
+ <Flex direction="column" gap="3" style={{ flex: 1, minWidth: 0 }}>
404
+ {criticMessages.map((msg, i) => (
405
+ <MessageBubble key={`critic-${i}`} msg={msg} />
406
+ ))}
407
+ </Flex>
408
+ </Flex>
409
+ )}
410
+
411
+ {/* Analyser synthesis — full width */}
412
+ {analyserSynthesis.map((msg, i) => (
413
+ <MessageBubble key={`analyser-synth-${i}`} msg={msg} />
414
+ ))}
415
+
416
+ <div ref={bottomRef} />
417
+ </Flex>
418
+ </Box>
419
+ </>
420
+ )
421
+ }
422
+
423
+ function ThemedApp() {
424
+ const { darkMode } = useDarkModeContext()
425
+ return (
426
+ <Theme
427
+ appearance={darkMode ? `dark` : `light`}
428
+ accentColor="blue"
429
+ radius="medium"
430
+ >
431
+ <App />
432
+ </Theme>
433
+ )
434
+ }
435
+
436
+ createRoot(document.getElementById(`root`)!).render(
437
+ <DarkModeProvider>
438
+ <ThemedApp />
439
+ </DarkModeProvider>
440
+ )