@soederpop/luca 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.
- package/.github/workflows/release.yaml +2 -0
- package/CNAME +1 -0
- package/assistants/codingAssistant/ABOUT.md +3 -1
- package/assistants/codingAssistant/CORE.md +2 -4
- package/assistants/codingAssistant/hooks.ts +9 -10
- package/assistants/codingAssistant/tools.ts +9 -0
- package/assistants/inkbot/ABOUT.md +13 -2
- package/assistants/inkbot/CORE.md +278 -39
- package/assistants/inkbot/hooks.ts +0 -8
- package/assistants/inkbot/tools.ts +24 -18
- package/assistants/researcher/ABOUT.md +5 -0
- package/assistants/researcher/CORE.md +46 -0
- package/assistants/researcher/hooks.ts +16 -0
- package/assistants/researcher/tools.ts +237 -0
- package/commands/inkbot.ts +526 -194
- package/docs/CNAME +1 -0
- package/docs/examples/assistant-hooks-reference.ts +171 -0
- package/index.html +1430 -0
- package/package.json +1 -1
- package/public/slides-ai-native.html +902 -0
- package/public/slides-intro.html +974 -0
- package/src/agi/features/assistant.ts +432 -62
- package/src/agi/features/conversation.ts +170 -10
- package/src/bootstrap/generated.ts +1 -1
- package/src/cli/build-info.ts +2 -2
- package/src/helper.ts +12 -3
- package/src/introspection/generated.agi.ts +1663 -644
- package/src/introspection/generated.node.ts +1637 -870
- package/src/introspection/generated.web.ts +1 -1
- package/src/python/generated.ts +1 -1
- package/src/scaffolds/generated.ts +1 -1
- package/test/assistant-hooks.test.ts +306 -0
- package/test/assistant.test.ts +1 -1
- package/test/fork-and-research.test.ts +450 -0
- package/SPEC.md +0 -304
package/commands/inkbot.ts
CHANGED
|
@@ -2,208 +2,437 @@ import { z } from 'zod'
|
|
|
2
2
|
import { commands, CommandOptionsSchema } from '@soederpop/luca'
|
|
3
3
|
import type { ContainerContext } from '@soederpop/luca'
|
|
4
4
|
import { AGIContainer } from '../src/agi/container.server.js'
|
|
5
|
-
import { tmpdir } from 'os'
|
|
6
5
|
|
|
7
6
|
export const argsSchema = CommandOptionsSchema.extend({
|
|
8
7
|
model: z.string().optional().describe('OpenAI model to use'),
|
|
9
8
|
})
|
|
10
9
|
|
|
10
|
+
// ─── Scene Types ──────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
interface SceneState {
|
|
13
|
+
code: string
|
|
14
|
+
component: ((...args: any[]) => any) | null
|
|
15
|
+
error: string
|
|
16
|
+
status: 'idle' | 'rendered' | 'interactive' | 'complete' | 'failed'
|
|
17
|
+
interactive: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Layout = 'split' | 'canvas' | 'chat'
|
|
21
|
+
const LAYOUT_CYCLE: Layout[] = ['split', 'canvas', 'chat']
|
|
22
|
+
|
|
23
|
+
function seedMentalState(ms: any) {
|
|
24
|
+
ms.set('activeSceneId', null)
|
|
25
|
+
ms.set('scenes', {} as Record<string, SceneState>)
|
|
26
|
+
ms.set('focus', 'chat' as 'chat' | 'canvas')
|
|
27
|
+
ms.set('layout', 'split' as Layout)
|
|
28
|
+
ms.set('thoughts', [] as Array<{ at: string; text: string }>)
|
|
29
|
+
ms.set('observations', {} as Record<string, string>)
|
|
30
|
+
ms.set('plan', '')
|
|
31
|
+
ms.set('mood', 'ready')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Safe Eval ────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
37
|
+
|
|
38
|
+
const EVAL_TIMEOUT = 15_000
|
|
39
|
+
|
|
40
|
+
async function evalWithTimeout<T>(fn: () => Promise<T>, ms: number): Promise<T> {
|
|
41
|
+
let timer: ReturnType<typeof setTimeout>
|
|
42
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
43
|
+
timer = setTimeout(() => reject(new Error(`Scene evaluation timed out after ${ms}ms`)), ms)
|
|
44
|
+
})
|
|
45
|
+
try {
|
|
46
|
+
return await Promise.race([fn(), timeout])
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer!)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Main Handler ─────────────────────────────────────────────────────
|
|
53
|
+
|
|
11
54
|
export async function inkbot(options: z.infer<typeof argsSchema>, context: ContainerContext) {
|
|
12
55
|
const container = new AGIContainer()
|
|
13
56
|
|
|
14
|
-
// ─── Load Ink
|
|
57
|
+
// ─── Load Ink ──────────────────────────────────────────────────────
|
|
15
58
|
const ink = container.feature('ink', { enable: true, patchConsole: true })
|
|
16
59
|
await ink.loadModules()
|
|
17
60
|
const React = ink.React
|
|
18
61
|
const h = React.createElement
|
|
19
|
-
const { Box, Text } = ink.components
|
|
62
|
+
const { Box, Text, Spacer, Newline } = ink.components
|
|
20
63
|
const { useInput, useApp, useStdout } = ink.hooks
|
|
21
|
-
const { useState, useEffect } = React
|
|
22
|
-
|
|
23
|
-
// ─── Scene Runner ────────────────────────────────────────────────────
|
|
24
|
-
// Scenes are code strings run as bun subprocesses.
|
|
25
|
-
// The stage entity tracks which scenes exist and which is active.
|
|
26
|
-
// Scene entities track individual code, output, error, status.
|
|
64
|
+
const { useState, useEffect, useRef, useCallback, useMemo } = React
|
|
27
65
|
|
|
28
|
-
|
|
29
|
-
|
|
66
|
+
// ─── Assistant ─────────────────────────────────────────────────────
|
|
67
|
+
const mgr = container.feature('assistantsManager')
|
|
68
|
+
await mgr.discover()
|
|
69
|
+
const assistant = mgr.create('inkbot', { model: options.model })
|
|
30
70
|
|
|
31
|
-
|
|
71
|
+
seedMentalState(assistant.mentalState)
|
|
32
72
|
|
|
33
|
-
|
|
34
|
-
|
|
73
|
+
// ─── Container features for scene scope ────────────────────────────
|
|
74
|
+
const fs = container.feature('fs')
|
|
75
|
+
const proc = container.feature('proc')
|
|
76
|
+
const ui = container.feature('ui')
|
|
77
|
+
const yaml = container.feature('yaml')
|
|
78
|
+
const grep = container.feature('grep')
|
|
79
|
+
const git = container.feature('git')
|
|
80
|
+
|
|
81
|
+
// ─── Scene Focus Context ───────────────────────────────────────────
|
|
82
|
+
// Scenes use useSceneInput() which is only active when canvas is focused.
|
|
83
|
+
const SceneFocusContext = React.createContext(false)
|
|
84
|
+
|
|
85
|
+
function makeUseSceneInput(onError: (err: Error) => void) {
|
|
86
|
+
return function useSceneInput(handler: (ch: string, key: any) => void) {
|
|
87
|
+
const focused = React.useContext(SceneFocusContext)
|
|
88
|
+
useInput(
|
|
89
|
+
(ch: string, key: any) => {
|
|
90
|
+
// Reserve Tab, Escape, and Ctrl+L (layout toggle) for the host app
|
|
91
|
+
if (key.tab || key.escape) return
|
|
92
|
+
if (key.ctrl && ch === 'l') return
|
|
93
|
+
try {
|
|
94
|
+
handler(ch, key)
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
onError(err)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{ isActive: focused },
|
|
100
|
+
)
|
|
101
|
+
}
|
|
35
102
|
}
|
|
36
103
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
104
|
+
// ─── Error Boundary ────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
class SceneErrorBoundary extends React.Component<
|
|
107
|
+
{ children: any; onError?: (err: Error) => void; fallback?: any },
|
|
108
|
+
{ error: Error | null }
|
|
109
|
+
> {
|
|
110
|
+
constructor(props: any) {
|
|
111
|
+
super(props)
|
|
112
|
+
this.state = { error: null }
|
|
113
|
+
}
|
|
114
|
+
static getDerivedStateFromError(error: Error) {
|
|
115
|
+
return { error }
|
|
116
|
+
}
|
|
117
|
+
componentDidCatch(error: Error) {
|
|
118
|
+
this.props.onError?.(error)
|
|
119
|
+
}
|
|
120
|
+
render() {
|
|
121
|
+
if (this.state.error) {
|
|
122
|
+
return h(
|
|
123
|
+
Box,
|
|
124
|
+
{ flexDirection: 'column', paddingX: 1 },
|
|
125
|
+
h(Text, { color: 'red', bold: true }, 'Render Error'),
|
|
126
|
+
h(Text, { color: 'red', wrap: 'wrap' }, this.state.error.message),
|
|
127
|
+
h(
|
|
128
|
+
Text,
|
|
129
|
+
{ dimColor: true, wrap: 'wrap' },
|
|
130
|
+
this.state.error.stack?.split('\n').slice(1, 4).join('\n') || '',
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
return this.props.children
|
|
41
135
|
}
|
|
42
|
-
const scene = container.entity(`inkbot:scene:${id}`)
|
|
43
|
-
scene.setState({ code, output: '', error: '', status: 'idle', exitCode: null })
|
|
44
|
-
sceneMap[id] = scene
|
|
45
|
-
const ids = [...((stage.state.get('sceneIds') || []) as string[])]
|
|
46
|
-
if (!ids.includes(id)) ids.push(id)
|
|
47
|
-
stage.setState({ sceneIds: ids })
|
|
48
|
-
if (!stage.state.get('activeSceneId')) stage.setState({ activeSceneId: id })
|
|
49
|
-
return scene
|
|
50
136
|
}
|
|
51
137
|
|
|
52
|
-
|
|
53
|
-
const fs = container.feature('fs')
|
|
54
|
-
const sceneTmpDir = `${tmpdir()}/inkbot-scenes`
|
|
55
|
-
fs.ensureFolder(sceneTmpDir)
|
|
138
|
+
// ─── Scene State Management ────────────────────────────────────────
|
|
56
139
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
140
|
+
let busy = false
|
|
141
|
+
assistant.on('turnStart', () => { busy = true })
|
|
142
|
+
assistant.on('response', () => { busy = false })
|
|
60
143
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
144
|
+
function getScenes(): Record<string, SceneState> {
|
|
145
|
+
return { ...(assistant.mentalState.get('scenes') || {}) }
|
|
146
|
+
}
|
|
64
147
|
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
148
|
+
function setScene(id: string, patch: Partial<SceneState>) {
|
|
149
|
+
const scenes = getScenes()
|
|
150
|
+
scenes[id] = {
|
|
151
|
+
...(scenes[id] || { code: '', component: null, error: '', status: 'idle', interactive: false }),
|
|
152
|
+
...patch,
|
|
153
|
+
}
|
|
154
|
+
assistant.mentalState.set('scenes', scenes)
|
|
155
|
+
}
|
|
68
156
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
157
|
+
// ─── Pending Responders ────────────────────────────────────────────
|
|
158
|
+
// For interactive scenes: draw() blocks until the component calls respond().
|
|
159
|
+
const pendingResponders = new Map<string, (result: any) => void>()
|
|
160
|
+
|
|
161
|
+
function createRespondForScene(sceneId: string) {
|
|
162
|
+
return (data: any) => {
|
|
163
|
+
const resolver = pendingResponders.get(sceneId)
|
|
164
|
+
if (resolver) {
|
|
165
|
+
pendingResponders.delete(sceneId)
|
|
166
|
+
setScene(sceneId, { status: 'complete' })
|
|
167
|
+
assistant.mentalState.set('focus', 'chat')
|
|
168
|
+
resolver({ status: 'completed', sceneId, response: data })
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Scene Error Reporter ──────────────────────────────────────────
|
|
174
|
+
// When a scene errors and the assistant is idle, feed the error back
|
|
175
|
+
// so it can self-correct.
|
|
176
|
+
function reportSceneError(sceneId: string, error: string) {
|
|
177
|
+
setScene(sceneId, { error, status: 'failed' })
|
|
178
|
+
if (!busy) {
|
|
179
|
+
const errMsg = `[Scene "${sceneId}" render error]\n${error}\n\nFix the component code and redraw.`
|
|
180
|
+
assistant.conversation?.pushMessage({ role: 'developer', content: errMsg })
|
|
181
|
+
assistant.ask(errMsg).catch(() => {})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
81
184
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
185
|
+
// ─── Scene Evaluator ──────────────────────────────────────────────
|
|
186
|
+
// Evaluates scene code in an async function scope with all APIs injected.
|
|
187
|
+
// The code must `return` a React component function.
|
|
188
|
+
|
|
189
|
+
async function evaluateScene(
|
|
190
|
+
code: string,
|
|
191
|
+
sceneId: string,
|
|
192
|
+
interactive: boolean,
|
|
193
|
+
): Promise<{ component: ((...args: any[]) => any) | null; error: string | null }> {
|
|
194
|
+
const respondFn = createRespondForScene(sceneId)
|
|
195
|
+
const sceneErrorHandler = (err: Error) => reportSceneError(sceneId, err.message)
|
|
196
|
+
const useSceneInput = makeUseSceneInput(sceneErrorHandler)
|
|
197
|
+
|
|
198
|
+
const paramNames = [
|
|
199
|
+
// React core
|
|
200
|
+
'h', 'React', 'Box', 'Text', 'Spacer', 'Newline',
|
|
201
|
+
// React hooks
|
|
202
|
+
'useState', 'useEffect', 'useRef', 'useCallback', 'useMemo',
|
|
203
|
+
// Scene input (focus-aware, error-safe)
|
|
204
|
+
'useSceneInput',
|
|
205
|
+
// Canvas API
|
|
206
|
+
'setMental', 'getMental', 'respond',
|
|
207
|
+
// Container
|
|
208
|
+
'container', 'fs', 'proc', 'ui', 'yaml', 'grep', 'git',
|
|
209
|
+
// Utilities
|
|
210
|
+
'fetch', 'URL', 'Buffer', 'JSON', 'Date', 'Math', 'console',
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
const paramValues = [
|
|
214
|
+
h, React, Box, Text, Spacer, Newline,
|
|
215
|
+
useState, useEffect, useRef, useCallback, useMemo,
|
|
216
|
+
useSceneInput,
|
|
217
|
+
(k: string, v: any) => assistant.mentalState.set(k, v),
|
|
218
|
+
(k: string) => assistant.mentalState.get(k),
|
|
219
|
+
respondFn,
|
|
220
|
+
container, fs, proc, ui, yaml, grep, git,
|
|
221
|
+
fetch, URL, Buffer, JSON, Date, Math, console,
|
|
222
|
+
]
|
|
86
223
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
224
|
+
try {
|
|
225
|
+
const factory = new AsyncFunction(...paramNames, code)
|
|
226
|
+
const result = await evalWithTimeout(() => factory(...paramValues), EVAL_TIMEOUT)
|
|
227
|
+
|
|
228
|
+
if (typeof result !== 'function') {
|
|
229
|
+
return {
|
|
230
|
+
component: null,
|
|
231
|
+
error: `Scene code must return a React component function, got ${typeof result}. End your code with: return function Scene() { return h(Box, {}, h(Text, {}, "hello")) }`,
|
|
232
|
+
}
|
|
91
233
|
}
|
|
234
|
+
|
|
235
|
+
return { component: result, error: null }
|
|
92
236
|
} catch (err: any) {
|
|
93
|
-
|
|
94
|
-
bumpStage()
|
|
95
|
-
try { fs.unlink(file) } catch {}
|
|
96
|
-
return { output: '', error: err.message, exitCode: 1 }
|
|
237
|
+
return { component: null, error: err.message }
|
|
97
238
|
}
|
|
98
239
|
}
|
|
99
240
|
|
|
100
|
-
// ───
|
|
101
|
-
const mgr = container.feature('assistantsManager')
|
|
102
|
-
await mgr.discover()
|
|
103
|
-
const assistant = mgr.create('inkbot', { model: options.model })
|
|
104
|
-
|
|
105
|
-
// Scene completion → inject result into conversation so the assistant sees it
|
|
106
|
-
stage.on('sceneComplete' as any, (id: string, output: string) => {
|
|
107
|
-
const msg = output.trim()
|
|
108
|
-
? `[Scene "${id}" completed]\n${output.trim()}`
|
|
109
|
-
: `[Scene "${id}" completed with no output]`
|
|
110
|
-
assistant.conversation?.pushMessage({ role: 'developer', content: msg })
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
stage.on('sceneFailed' as any, (id: string, error: string) => {
|
|
114
|
-
const msg = `[Scene "${id}" failed]\n${error.trim()}`
|
|
115
|
-
assistant.conversation?.pushMessage({ role: 'developer', content: msg })
|
|
116
|
-
// Auto-ask the assistant to fix it
|
|
117
|
-
assistant.ask(msg).catch(() => {})
|
|
118
|
-
})
|
|
241
|
+
// ─── Canvas Tools ─────────────────────────────────────────────────
|
|
119
242
|
|
|
120
|
-
// Canvas tools — registered directly so they close over stage/sceneMap
|
|
121
243
|
assistant.addTool(
|
|
122
244
|
'draw',
|
|
123
|
-
async (args: { code: string; sceneId?: string }) => {
|
|
245
|
+
async (args: { code: string; sceneId?: string; interactive?: boolean }) => {
|
|
124
246
|
const id = args.sceneId || 'default'
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
})
|
|
136
|
-
|
|
247
|
+
const interactive = !!args.interactive
|
|
248
|
+
|
|
249
|
+
const { component, error } = await evaluateScene(args.code, id, interactive)
|
|
250
|
+
|
|
251
|
+
if (error) {
|
|
252
|
+
setScene(id, { code: args.code, component: null, error, status: 'failed', interactive })
|
|
253
|
+
assistant.mentalState.set('activeSceneId', id)
|
|
254
|
+
return { status: 'failed', sceneId: id, error }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
setScene(id, { code: args.code, component, error: '', status: interactive ? 'interactive' : 'rendered', interactive })
|
|
258
|
+
assistant.mentalState.set('activeSceneId', id)
|
|
259
|
+
|
|
260
|
+
if (interactive) {
|
|
261
|
+
// Block until the component calls respond()
|
|
262
|
+
return new Promise<any>((resolve) => {
|
|
263
|
+
pendingResponders.set(id, resolve)
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { status: 'rendered', sceneId: id }
|
|
137
268
|
},
|
|
138
269
|
z.object({
|
|
139
|
-
code: z
|
|
270
|
+
code: z
|
|
271
|
+
.string()
|
|
272
|
+
.describe(
|
|
273
|
+
'Async function body that returns a React component function. Use h() for elements. Has access to: h, React, Box, Text, Spacer, Newline, useState, useEffect, useRef, useCallback, useMemo, useSceneInput, setMental, getMental, respond, container, fs, proc, ui, yaml, grep, git, fetch.',
|
|
274
|
+
),
|
|
140
275
|
sceneId: z.string().optional().describe('Scene id (defaults to "default").'),
|
|
141
|
-
|
|
276
|
+
interactive: z
|
|
277
|
+
.boolean()
|
|
278
|
+
.optional()
|
|
279
|
+
.describe('When true, the tool call blocks until the component calls respond(data). The component should use useSceneInput() for keyboard input.'),
|
|
280
|
+
}).describe(
|
|
281
|
+
'Render a React Ink component in the canvas pane. The code runs as an async function body and must return a React component function. Interactive scenes block until respond(data) is called.',
|
|
282
|
+
),
|
|
142
283
|
)
|
|
143
284
|
|
|
144
285
|
assistant.addTool(
|
|
145
286
|
'create_scene',
|
|
146
|
-
async (args: { id: string; code: string }) => {
|
|
147
|
-
|
|
148
|
-
|
|
287
|
+
async (args: { id: string; code: string; interactive?: boolean }) => {
|
|
288
|
+
const { component, error } = await evaluateScene(args.code, args.id, !!args.interactive)
|
|
289
|
+
if (error) {
|
|
290
|
+
setScene(args.id, { code: args.code, component: null, error, status: 'failed', interactive: !!args.interactive })
|
|
291
|
+
return { created: args.id, error }
|
|
292
|
+
}
|
|
293
|
+
setScene(args.id, { code: args.code, component, error: '', status: 'idle', interactive: !!args.interactive })
|
|
294
|
+
if (!assistant.mentalState.get('activeSceneId')) {
|
|
295
|
+
assistant.mentalState.set('activeSceneId', args.id)
|
|
296
|
+
}
|
|
297
|
+
return { created: args.id, allScenes: Object.keys(getScenes()) }
|
|
149
298
|
},
|
|
150
299
|
z.object({
|
|
151
300
|
id: z.string().describe('Unique scene identifier'),
|
|
152
|
-
code: z.string().describe('
|
|
153
|
-
|
|
301
|
+
code: z.string().describe('Async function body returning a React component function'),
|
|
302
|
+
interactive: z.boolean().optional().describe('Whether this scene uses interactive APIs'),
|
|
303
|
+
}).describe('Create a named scene without activating it. Validates the code immediately.'),
|
|
154
304
|
)
|
|
155
305
|
|
|
156
306
|
assistant.addTool(
|
|
157
|
-
'
|
|
158
|
-
async (args: { id: string }) =>
|
|
307
|
+
'activate_scene',
|
|
308
|
+
async (args: { id: string }) => {
|
|
309
|
+
const scenes = getScenes()
|
|
310
|
+
if (!scenes[args.id]) return { error: `Scene "${args.id}" not found` }
|
|
311
|
+
assistant.mentalState.set('activeSceneId', args.id)
|
|
312
|
+
return { activeSceneId: args.id }
|
|
313
|
+
},
|
|
159
314
|
z.object({
|
|
160
|
-
id: z.string().describe('Scene id to
|
|
161
|
-
}).describe('
|
|
315
|
+
id: z.string().describe('Scene id to display in the canvas'),
|
|
316
|
+
}).describe('Switch the canvas to display a different scene.'),
|
|
162
317
|
)
|
|
163
318
|
|
|
164
319
|
assistant.addTool(
|
|
165
|
-
'
|
|
320
|
+
'get_canvas',
|
|
166
321
|
async () => {
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
322
|
+
const activeId = assistant.mentalState.get('activeSceneId') as string | null
|
|
323
|
+
const scenes = getScenes()
|
|
324
|
+
if (!activeId || !scenes[activeId]) return { status: 'empty', allScenes: [] }
|
|
325
|
+
const s = scenes[activeId]
|
|
326
|
+
return { sceneId: activeId, status: s.status, error: s.error, interactive: s.interactive, allScenes: Object.keys(scenes) }
|
|
171
327
|
},
|
|
172
|
-
z.object({}).describe('
|
|
328
|
+
z.object({}).describe('Inspect the current canvas state: active scene, status, errors, scene list.'),
|
|
173
329
|
)
|
|
174
330
|
|
|
331
|
+
// ─── Mental State Tools ───────────────────────────────────────────
|
|
332
|
+
|
|
175
333
|
assistant.addTool(
|
|
176
|
-
'
|
|
334
|
+
'think',
|
|
335
|
+
async (args: { text: string }) => {
|
|
336
|
+
const thoughts = [...(assistant.mentalState.get('thoughts') || [])]
|
|
337
|
+
thoughts.push({ at: new Date().toISOString(), text: args.text })
|
|
338
|
+
if (thoughts.length > 50) thoughts.splice(0, thoughts.length - 50)
|
|
339
|
+
assistant.mentalState.set('thoughts', thoughts)
|
|
340
|
+
return { recorded: true, totalThoughts: thoughts.length }
|
|
341
|
+
},
|
|
342
|
+
z.object({
|
|
343
|
+
text: z.string().describe('Your thought, observation, or internal note.'),
|
|
344
|
+
}).describe('Record a thought in your mental state.'),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
assistant.addTool(
|
|
348
|
+
'observe',
|
|
349
|
+
async (args: { key: string; value: string }) => {
|
|
350
|
+
const observations = { ...(assistant.mentalState.get('observations') || {}) }
|
|
351
|
+
observations[args.key] = args.value
|
|
352
|
+
assistant.mentalState.set('observations', observations)
|
|
353
|
+
return { recorded: true, key: args.key }
|
|
354
|
+
},
|
|
355
|
+
z.object({
|
|
356
|
+
key: z.string().describe('A short label for what you observed.'),
|
|
357
|
+
value: z.string().describe('Your observation.'),
|
|
358
|
+
}).describe('Record a named observation.'),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
assistant.addTool(
|
|
362
|
+
'set_plan',
|
|
363
|
+
async (args: { plan: string }) => {
|
|
364
|
+
assistant.mentalState.set('plan', args.plan)
|
|
365
|
+
return { updated: true }
|
|
366
|
+
},
|
|
367
|
+
z.object({
|
|
368
|
+
plan: z.string().describe('Your current plan of action.'),
|
|
369
|
+
}).describe('Set or update your current plan.'),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
assistant.addTool(
|
|
373
|
+
'set_mood',
|
|
374
|
+
async (args: { mood: string }) => {
|
|
375
|
+
assistant.mentalState.set('mood', args.mood)
|
|
376
|
+
return { updated: true }
|
|
377
|
+
},
|
|
378
|
+
z.object({
|
|
379
|
+
mood: z.string().describe('A word or short phrase describing your current state.'),
|
|
380
|
+
}).describe('Update your mood/status displayed in the UI header.'),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
assistant.addTool(
|
|
384
|
+
'reflect',
|
|
177
385
|
async () => {
|
|
178
|
-
const activeId = stage.state.get('activeSceneId') as string | null
|
|
179
|
-
if (!activeId || !sceneMap[activeId]) return { status: 'empty', allScenes: [] }
|
|
180
|
-
const s = sceneMap[activeId]
|
|
181
386
|
return {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
387
|
+
mood: assistant.mentalState.get('mood'),
|
|
388
|
+
plan: assistant.mentalState.get('plan'),
|
|
389
|
+
thoughts: assistant.mentalState.get('thoughts'),
|
|
390
|
+
observations: assistant.mentalState.get('observations'),
|
|
391
|
+
scenes: Object.keys(getScenes()),
|
|
392
|
+
activeScene: assistant.mentalState.get('activeSceneId'),
|
|
188
393
|
}
|
|
189
394
|
},
|
|
190
|
-
z.object({}).describe('
|
|
395
|
+
z.object({}).describe('Review your full mental state.'),
|
|
191
396
|
)
|
|
192
397
|
|
|
398
|
+
// ─── Coder Subagent ───────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
let coderAssistant: any = null
|
|
401
|
+
|
|
402
|
+
const CODER_PREFIX = `You are answering a question from Inkbot, a canvas-rendering assistant that renders React Ink components directly in a split-pane terminal UI.
|
|
403
|
+
|
|
404
|
+
IMPORTANT CONTEXT about Inkbot's execution environment:
|
|
405
|
+
- Scene code is an async function body that must RETURN a React component function
|
|
406
|
+
- Uses h() (React.createElement) instead of JSX — no JSX compilation available
|
|
407
|
+
- Available in scope: h, React, Box, Text, Spacer, Newline, useState, useEffect, useRef, useCallback, useMemo
|
|
408
|
+
- useSceneInput(handler) for keyboard input (focus-aware, error-safe — NOT raw useInput)
|
|
409
|
+
- setMental(key, value) and getMental(key) for assistant mental state
|
|
410
|
+
- respond(data) to complete interactive scenes
|
|
411
|
+
- container, fs, proc, ui, yaml, grep, git — all Luca container features
|
|
412
|
+
- fetch, URL, Buffer, JSON, Date, Math, console — standard globals
|
|
413
|
+
- Top-level await works (the wrapper is async)
|
|
414
|
+
- No imports possible — everything via scope injection
|
|
415
|
+
|
|
416
|
+
When answering, return a SINGLE code snippet that works as a scene code body. Must end with \`return function SceneName() { ... }\`.
|
|
417
|
+
|
|
418
|
+
Inkbot's question: `
|
|
419
|
+
|
|
193
420
|
assistant.addTool(
|
|
194
|
-
'
|
|
195
|
-
async (args: {
|
|
196
|
-
if (!
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
421
|
+
'ask_coder',
|
|
422
|
+
async (args: { question: string }) => {
|
|
423
|
+
if (!coderAssistant) {
|
|
424
|
+
coderAssistant = mgr.create('codingAssistant', { model: options.model || 'gpt-5.4' })
|
|
425
|
+
await coderAssistant.start()
|
|
426
|
+
}
|
|
427
|
+
const answer = await coderAssistant.ask(CODER_PREFIX + args.question)
|
|
428
|
+
return { answer }
|
|
200
429
|
},
|
|
201
430
|
z.object({
|
|
202
|
-
|
|
203
|
-
}).describe('
|
|
431
|
+
question: z.string().describe('Your question about the Luca framework, container APIs, or how to write scene components.'),
|
|
432
|
+
}).describe('Ask the coding assistant a question about the Luca framework or how to build scene components.'),
|
|
204
433
|
)
|
|
205
434
|
|
|
206
|
-
// ─── Ink App
|
|
435
|
+
// ─── Ink App ──────────────────────────────────────────────────────
|
|
207
436
|
|
|
208
437
|
type Msg = { role: 'user' | 'assistant' | 'system'; content: string }
|
|
209
438
|
|
|
@@ -213,7 +442,13 @@ export async function inkbot(options: z.infer<typeof argsSchema>, context: Conta
|
|
|
213
442
|
const [streaming, setStreaming] = useState('')
|
|
214
443
|
const [thinking, setThinking] = useState(false)
|
|
215
444
|
const [activity, setActivity] = useState('')
|
|
216
|
-
const [
|
|
445
|
+
const [canvasError, setCanvasError] = useState('')
|
|
446
|
+
const [canvasStatus, setCanvasStatus] = useState('empty')
|
|
447
|
+
const [mood, setMood] = useState('ready')
|
|
448
|
+
const [focus, setFocus] = useState<'chat' | 'canvas'>('chat')
|
|
449
|
+
const [layout, setLayout] = useState<Layout>('split')
|
|
450
|
+
const [activeComponent, setActiveComponent] = useState<((...args: any[]) => any) | null>(null)
|
|
451
|
+
const [errorBoundaryKey, setErrorBoundaryKey] = useState(0)
|
|
217
452
|
const { exit } = useApp()
|
|
218
453
|
const { stdout } = useStdout()
|
|
219
454
|
const rows = stdout?.rows ?? 24
|
|
@@ -221,127 +456,224 @@ export async function inkbot(options: z.infer<typeof argsSchema>, context: Conta
|
|
|
221
456
|
// --- assistant events ---
|
|
222
457
|
useEffect(() => {
|
|
223
458
|
const onPreview = (text: string) => setStreaming(text)
|
|
224
|
-
const onResponse = (text: string) => {
|
|
225
|
-
setStreaming('')
|
|
226
|
-
setThinking(false)
|
|
227
|
-
setActivity('')
|
|
228
|
-
setMessages(prev => [...prev, { role: 'assistant', content: text }])
|
|
229
|
-
}
|
|
230
459
|
const onToolCall = (name: string) => setActivity(`${name}`)
|
|
231
460
|
const onToolResult = () => setActivity('')
|
|
232
461
|
|
|
233
462
|
assistant.on('preview', onPreview)
|
|
234
|
-
assistant.on('response', onResponse)
|
|
235
463
|
assistant.on('toolCall', onToolCall)
|
|
236
464
|
assistant.on('toolResult', onToolResult)
|
|
237
465
|
return () => {
|
|
238
466
|
assistant.off('preview', onPreview)
|
|
239
|
-
assistant.off('response', onResponse)
|
|
240
467
|
assistant.off('toolCall', onToolCall)
|
|
241
468
|
assistant.off('toolResult', onToolResult)
|
|
242
469
|
}
|
|
243
470
|
}, [])
|
|
244
471
|
|
|
245
|
-
// ---
|
|
472
|
+
// --- mentalState → UI state ---
|
|
246
473
|
useEffect(() => {
|
|
247
|
-
const unsub =
|
|
248
|
-
if (key
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
474
|
+
const unsub = assistant.mentalState.observe((_changeType: any, key: any) => {
|
|
475
|
+
if (key === 'scenes' || key === 'activeSceneId') {
|
|
476
|
+
const activeId = assistant.mentalState.get('activeSceneId') as string | null
|
|
477
|
+
const scenes = (assistant.mentalState.get('scenes') as Record<string, SceneState>) || {}
|
|
478
|
+
if (!activeId || !scenes[activeId]) {
|
|
479
|
+
setActiveComponent(null)
|
|
480
|
+
setCanvasError('')
|
|
481
|
+
setCanvasStatus('empty')
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
const s = scenes[activeId]
|
|
485
|
+
setActiveComponent(() => s.component)
|
|
486
|
+
setCanvasError(s.error)
|
|
487
|
+
setCanvasStatus(s.status)
|
|
488
|
+
// Reset error boundary when component changes
|
|
489
|
+
setErrorBoundaryKey((k) => k + 1)
|
|
490
|
+
}
|
|
491
|
+
if (key === 'mood') {
|
|
492
|
+
setMood((assistant.mentalState.get('mood') || 'ready') as string)
|
|
493
|
+
}
|
|
494
|
+
if (key === 'focus') {
|
|
495
|
+
setFocus((assistant.mentalState.get('focus') || 'chat') as 'chat' | 'canvas')
|
|
496
|
+
}
|
|
497
|
+
if (key === 'layout') {
|
|
498
|
+
setLayout((assistant.mentalState.get('layout') || 'split') as Layout)
|
|
253
499
|
}
|
|
254
|
-
const s = sceneMap[activeId]
|
|
255
|
-
setCanvas({
|
|
256
|
-
output: (s.state.get('output') || '') as string,
|
|
257
|
-
error: (s.state.get('error') || '') as string,
|
|
258
|
-
status: (s.state.get('status') || 'idle') as string,
|
|
259
|
-
})
|
|
260
500
|
})
|
|
261
501
|
return unsub
|
|
262
502
|
}, [])
|
|
263
503
|
|
|
264
|
-
// --- keyboard ---
|
|
504
|
+
// --- keyboard (host-level) ---
|
|
265
505
|
useInput((ch, key) => {
|
|
266
|
-
if (key.escape) {
|
|
506
|
+
if (key.escape) {
|
|
507
|
+
exit()
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Tab toggles focus
|
|
512
|
+
if (key.tab) {
|
|
513
|
+
const next = focus === 'chat' ? 'canvas' : 'chat'
|
|
514
|
+
setFocus(next)
|
|
515
|
+
assistant.mentalState.set('focus', next)
|
|
516
|
+
return
|
|
517
|
+
}
|
|
267
518
|
|
|
519
|
+
// When canvas is focused, only the scene component handles input
|
|
520
|
+
// (via useSceneInput which filters Tab/Escape)
|
|
521
|
+
if (focus === 'canvas') return
|
|
522
|
+
|
|
523
|
+
// Chat-focused input
|
|
268
524
|
if (key.return) {
|
|
269
525
|
if (thinking) return
|
|
270
526
|
const msg = input.trim()
|
|
271
527
|
if (!msg) return
|
|
272
528
|
setInput('')
|
|
273
|
-
setMessages(prev => [...prev, { role: 'user', content: msg }])
|
|
529
|
+
setMessages((prev) => [...prev, { role: 'user', content: msg }])
|
|
274
530
|
setThinking(true)
|
|
275
|
-
assistant
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
531
|
+
assistant
|
|
532
|
+
.ask(msg)
|
|
533
|
+
.then((text: string) => {
|
|
534
|
+
setStreaming('')
|
|
535
|
+
setThinking(false)
|
|
536
|
+
setActivity('')
|
|
537
|
+
setMessages((prev) => [...prev, { role: 'assistant', content: text }])
|
|
538
|
+
})
|
|
539
|
+
.catch((err: any) => {
|
|
540
|
+
setStreaming('')
|
|
541
|
+
setThinking(false)
|
|
542
|
+
setActivity('')
|
|
543
|
+
setMessages((prev) => [...prev, { role: 'system', content: `error: ${err.message}` }])
|
|
544
|
+
})
|
|
279
545
|
return
|
|
280
546
|
}
|
|
281
547
|
|
|
282
548
|
if (key.backspace || key.delete) {
|
|
283
|
-
setInput(prev => prev.slice(0, -1))
|
|
549
|
+
setInput((prev) => prev.slice(0, -1))
|
|
284
550
|
return
|
|
285
551
|
}
|
|
286
552
|
|
|
287
553
|
if (ch && !key.ctrl && !key.meta) {
|
|
288
|
-
setInput(prev => prev + ch)
|
|
554
|
+
setInput((prev) => prev + ch)
|
|
289
555
|
}
|
|
290
556
|
})
|
|
291
557
|
|
|
292
558
|
// --- render ---
|
|
293
559
|
const visible = messages.slice(-30)
|
|
294
|
-
const
|
|
295
|
-
const
|
|
560
|
+
const scenes = (assistant.mentalState.get('scenes') as Record<string, SceneState>) || {}
|
|
561
|
+
const sceneIds = Object.keys(scenes)
|
|
562
|
+
const activeId = (assistant.mentalState.get('activeSceneId') || '') as string
|
|
563
|
+
const chatFocused = focus === 'chat'
|
|
564
|
+
const canvasFocused = focus === 'canvas'
|
|
296
565
|
|
|
297
566
|
const chatChildren: any[] = []
|
|
298
|
-
|
|
299
567
|
visible.forEach((m, i) => {
|
|
300
568
|
const color = m.role === 'user' ? 'green' : m.role === 'system' ? 'red' : 'white'
|
|
301
569
|
const prefix = m.role === 'user' ? '> ' : ' '
|
|
302
570
|
chatChildren.push(h(Text, { key: `msg-${i}`, wrap: 'wrap', color }, `${prefix}${m.content}`))
|
|
303
571
|
})
|
|
304
|
-
|
|
305
572
|
if (streaming) chatChildren.push(h(Text, { key: 'chat-stream', wrap: 'wrap', dimColor: true }, ` ${streaming}`))
|
|
306
573
|
if (thinking && !streaming) chatChildren.push(h(Text, { key: 'chat-think', color: 'yellow' }, ' thinking...'))
|
|
307
574
|
if (activity) chatChildren.push(h(Text, { key: 'chat-act', color: 'blue' }, ` [${activity}]`))
|
|
308
575
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
576
|
+
// Canvas content — either the active scene component or placeholders
|
|
577
|
+
let canvasContent: any
|
|
578
|
+
if (activeComponent) {
|
|
579
|
+
canvasContent = h(
|
|
580
|
+
SceneFocusContext.Provider,
|
|
581
|
+
{ value: canvasFocused },
|
|
582
|
+
h(
|
|
583
|
+
SceneErrorBoundary,
|
|
584
|
+
{
|
|
585
|
+
key: errorBoundaryKey,
|
|
586
|
+
onError: (err: Error) => reportSceneError(activeId, err.message),
|
|
587
|
+
},
|
|
588
|
+
h(activeComponent),
|
|
589
|
+
),
|
|
590
|
+
)
|
|
591
|
+
} else if (canvasError) {
|
|
592
|
+
canvasContent = h(
|
|
593
|
+
Box,
|
|
594
|
+
{ flexDirection: 'column', paddingX: 1 },
|
|
595
|
+
h(Text, { color: 'red', bold: true }, 'Error'),
|
|
596
|
+
h(Text, { color: 'red', wrap: 'wrap' }, canvasError),
|
|
597
|
+
)
|
|
598
|
+
} else {
|
|
599
|
+
canvasContent = h(Text, { dimColor: true, key: 'cvs-empty' }, ' ask inkbot to draw something')
|
|
600
|
+
}
|
|
317
601
|
|
|
318
|
-
return h(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
602
|
+
return h(
|
|
603
|
+
Box,
|
|
604
|
+
{ flexDirection: 'row', width: '100%', height: rows },
|
|
605
|
+
// ── Chat Pane ──
|
|
606
|
+
h(
|
|
607
|
+
Box,
|
|
608
|
+
{
|
|
609
|
+
key: 'chat',
|
|
610
|
+
flexDirection: 'column',
|
|
611
|
+
width: '50%',
|
|
612
|
+
height: rows,
|
|
613
|
+
borderStyle: 'round',
|
|
614
|
+
borderColor: chatFocused ? 'cyan' : 'gray',
|
|
615
|
+
paddingX: 1,
|
|
616
|
+
},
|
|
617
|
+
h(
|
|
618
|
+
Text,
|
|
619
|
+
{ bold: true, color: chatFocused ? 'cyan' : 'gray' },
|
|
620
|
+
' inkbot ',
|
|
621
|
+
h(Text, { dimColor: true }, `[${mood}]`),
|
|
622
|
+
!chatFocused ? h(Text, { dimColor: true }, ' (tab to focus)') : null,
|
|
623
|
+
),
|
|
322
624
|
h(Box, { flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }, ...chatChildren),
|
|
323
|
-
h(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
625
|
+
h(
|
|
626
|
+
Box,
|
|
627
|
+
{
|
|
628
|
+
borderStyle: 'single',
|
|
629
|
+
borderColor: chatFocused ? 'gray' : 'blackBright',
|
|
630
|
+
paddingX: 1,
|
|
631
|
+
},
|
|
632
|
+
h(Text, { color: chatFocused ? 'green' : 'blackBright' }, '> '),
|
|
633
|
+
h(Text, { dimColor: !chatFocused }, input),
|
|
634
|
+
chatFocused ? h(Text, { dimColor: true }, '\u2588') : null,
|
|
327
635
|
),
|
|
328
636
|
),
|
|
329
|
-
// ── Canvas ──
|
|
330
|
-
h(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
637
|
+
// ── Canvas Pane ──
|
|
638
|
+
h(
|
|
639
|
+
Box,
|
|
640
|
+
{
|
|
641
|
+
key: 'canvas',
|
|
642
|
+
flexDirection: 'column',
|
|
643
|
+
width: '50%',
|
|
644
|
+
height: rows,
|
|
645
|
+
borderStyle: 'round',
|
|
646
|
+
borderColor: canvasFocused ? 'magenta' : 'gray',
|
|
647
|
+
paddingX: 1,
|
|
648
|
+
},
|
|
649
|
+
h(
|
|
650
|
+
Text,
|
|
651
|
+
{ bold: true, color: canvasFocused ? 'magenta' : 'gray' },
|
|
652
|
+
' canvas ',
|
|
653
|
+
canvasStatus === 'interactive'
|
|
654
|
+
? h(Text, { color: 'yellow' }, '[interactive]')
|
|
655
|
+
: null,
|
|
656
|
+
!canvasFocused && canvasStatus === 'interactive'
|
|
657
|
+
? h(Text, { dimColor: true }, ' (tab to focus)')
|
|
658
|
+
: null,
|
|
659
|
+
),
|
|
660
|
+
h(
|
|
661
|
+
Box,
|
|
662
|
+
{ flexDirection: 'column', flexGrow: 1, overflow: 'hidden' },
|
|
663
|
+
canvasContent,
|
|
335
664
|
),
|
|
336
|
-
h(
|
|
337
|
-
|
|
338
|
-
|
|
665
|
+
h(
|
|
666
|
+
Box,
|
|
667
|
+
null,
|
|
668
|
+
h(Text, { dimColor: true }, ` ${canvasStatus}`),
|
|
669
|
+
sceneIds.length > 1
|
|
670
|
+
? h(Text, { dimColor: true }, ` scenes: ${sceneIds.join(', ')} active: ${activeId}`)
|
|
671
|
+
: null,
|
|
339
672
|
),
|
|
340
673
|
),
|
|
341
674
|
)
|
|
342
675
|
}
|
|
343
676
|
|
|
344
|
-
// Mount and hold
|
|
345
677
|
await ink.render(h(App))
|
|
346
678
|
await ink.waitUntilExit()
|
|
347
679
|
}
|