nebula-ai-plugin-system 0.1.0

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.
@@ -0,0 +1,255 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process'
2
+ import {
3
+ LocalBackend,
4
+ type SandboxBackend,
5
+ type ToolDef,
6
+ coerceBool,
7
+ redactEnv,
8
+ } from 'nebula-ai-core'
9
+ import { z } from 'zod'
10
+ import { type WorkingDirState, resolveCwd } from './cwd-state'
11
+
12
+ /**
13
+ * v0.9.3 split: long-running subprocess management is FOUR flat tools, not
14
+ * a discriminated union. qwen3.6-plus narrates results when faced with
15
+ * `action: 'start'|'output'|'list'|'kill'` schemas (regression #1). Flat
16
+ * z.object schemas remove that footgun.
17
+ *
18
+ * - `shell.process_start` — spawn a backgrounded command, returns id
19
+ * - `shell.process_output` — read stdout/stderr by id
20
+ * - `shell.process_list` — list active + recently-exited entries
21
+ * - `shell.process_kill` — terminate by id (SIGTERM / SIGKILL / SIGINT)
22
+ *
23
+ * State lives in module-level memory shared across the four tools; the
24
+ * process tree is killed when nebula exits via killAllProcesses().
25
+ *
26
+ * Distinct from `shell.run` which waits for completion (one-shot
27
+ * commands). shell.process_start backgrounds it (dev servers, watchers).
28
+ */
29
+
30
+ interface BackgroundProcess {
31
+ id: string
32
+ command: string
33
+ cwd: string
34
+ proc: ChildProcess
35
+ stdout: string
36
+ stderr: string
37
+ startedAt: number
38
+ exitCode: number | null
39
+ exitedAt: number | null
40
+ }
41
+
42
+ const processes = new Map<string, BackgroundProcess>()
43
+
44
+ interface ShellProcessDeps {
45
+ /**
46
+ * Working directory. Pass a `WorkingDirState` to share with `shell.cd`
47
+ * (production); a plain string for a fixed cwd (tests).
48
+ */
49
+ cwd: string | WorkingDirState
50
+ /** Phase 9.5: sandbox wraps the long-running spawn. LocalBackend = passthrough. Optional for back-compat. */
51
+ sandbox?: SandboxBackend
52
+ }
53
+
54
+ const StartSchema = z.object({
55
+ command: z.string().min(1).describe('Command to run via /bin/sh -c, in the background.'),
56
+ cwd: z.string().optional().describe('Working directory override. Defaults to nebula cwd.'),
57
+ })
58
+
59
+ const OutputSchema = z.object({
60
+ id: z.string().min(1).describe('Process id from a prior shell.process_start.'),
61
+ clear: coerceBool.optional().describe('Clear captured output after returning. Default false.'),
62
+ })
63
+
64
+ const ListSchema = z.object({})
65
+
66
+ const KillSchema = z.object({
67
+ id: z.string().min(1).describe('Process id from a prior shell.process_start.'),
68
+ signal: z
69
+ .enum(['SIGTERM', 'SIGKILL', 'SIGINT'])
70
+ .optional()
71
+ .describe('Signal to send. Default SIGTERM.'),
72
+ })
73
+
74
+ export function makeShellProcessStart(
75
+ deps: ShellProcessDeps,
76
+ ): ToolDef<z.infer<typeof StartSchema>> {
77
+ const sandbox = deps.sandbox ?? new LocalBackend()
78
+ const cwdState = resolveCwd(deps.cwd)
79
+ return {
80
+ name: 'shell.process_start',
81
+ description:
82
+ 'Spawn a backgrounded shell command and return its id. Use for dev servers, watchers, REPLs, anything you need to keep running while you do other things. For one-shot commands, use shell.run instead.',
83
+ searchHint: 'shell process spawn background daemon long running start',
84
+ schema: StartSchema,
85
+ handler: async args => startProcess(args.command, args.cwd ?? cwdState.get(), sandbox),
86
+ }
87
+ }
88
+
89
+ export function makeShellProcessOutput(): ToolDef<z.infer<typeof OutputSchema>> {
90
+ return {
91
+ name: 'shell.process_output',
92
+ description:
93
+ 'Read accumulated stdout + stderr of a backgrounded process by id. Returns running flag and exit_code (null while running). Set clear=true to drain the buffer after reading.',
94
+ searchHint: 'shell process output stdout stderr capture read',
95
+ schema: OutputSchema,
96
+ handler: async args => captureOutput(args.id, args.clear ?? false),
97
+ }
98
+ }
99
+
100
+ export function makeShellProcessList(): ToolDef<z.infer<typeof ListSchema>> {
101
+ return {
102
+ name: 'shell.process_list',
103
+ description:
104
+ 'List all backgrounded processes (active and recently-exited) with their commands, cwd, and status.',
105
+ searchHint: 'shell process list active running daemons subprocesses',
106
+ schema: ListSchema,
107
+ handler: async () => listProcesses(),
108
+ }
109
+ }
110
+
111
+ export function makeShellProcessKill(): ToolDef<z.infer<typeof KillSchema>> {
112
+ return {
113
+ name: 'shell.process_kill',
114
+ description:
115
+ 'Terminate a backgrounded process by id. Default signal is SIGTERM. Returns killed=true if a live process was signalled, killed=false if it had already exited.',
116
+ searchHint: 'shell process kill terminate sigterm sigkill stop',
117
+ schema: KillSchema,
118
+ handler: async args => killProcess(args.id, args.signal ?? 'SIGTERM'),
119
+ }
120
+ }
121
+
122
+ async function startProcess(
123
+ command: string,
124
+ cwd: string,
125
+ sandbox: SandboxBackend,
126
+ ): Promise<{ ok: boolean; data?: { id: string }; error?: string }> {
127
+ const id = `proc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
128
+ const { env } = redactEnv(process.env as Record<string, string>)
129
+ const wrapped = await sandbox.wrapSpawn({
130
+ command: '/bin/sh',
131
+ args: ['-c', command],
132
+ options: { cwd, env },
133
+ })
134
+ const proc = spawn(wrapped.command, wrapped.args, wrapped.options)
135
+ const entry: BackgroundProcess = {
136
+ id,
137
+ command,
138
+ cwd,
139
+ proc,
140
+ stdout: '',
141
+ stderr: '',
142
+ startedAt: Date.now(),
143
+ exitCode: null,
144
+ exitedAt: null,
145
+ }
146
+ processes.set(id, entry)
147
+ proc.stdout?.setEncoding('utf8')
148
+ proc.stderr?.setEncoding('utf8')
149
+ proc.stdout?.on('data', chunk => {
150
+ entry.stdout += chunk as string
151
+ if (entry.stdout.length > 200_000) entry.stdout = entry.stdout.slice(-200_000)
152
+ })
153
+ proc.stderr?.on('data', chunk => {
154
+ entry.stderr += chunk as string
155
+ if (entry.stderr.length > 100_000) entry.stderr = entry.stderr.slice(-100_000)
156
+ })
157
+ proc.on('exit', code => {
158
+ entry.exitCode = code
159
+ entry.exitedAt = Date.now()
160
+ })
161
+ return { ok: true, data: { id } }
162
+ }
163
+
164
+ function captureOutput(
165
+ id: string,
166
+ clear: boolean,
167
+ ): {
168
+ ok: boolean
169
+ data?: {
170
+ id: string
171
+ stdout: string
172
+ stderr: string
173
+ exit_code: number | null
174
+ running: boolean
175
+ started_at: number
176
+ exited_at: number | null
177
+ }
178
+ error?: string
179
+ } {
180
+ const entry = processes.get(id)
181
+ if (!entry) return { ok: false, error: `unknown process: ${id}` }
182
+ const out = {
183
+ id,
184
+ stdout: entry.stdout,
185
+ stderr: entry.stderr,
186
+ exit_code: entry.exitCode,
187
+ running: entry.exitCode === null,
188
+ started_at: entry.startedAt,
189
+ exited_at: entry.exitedAt,
190
+ }
191
+ if (clear) {
192
+ entry.stdout = ''
193
+ entry.stderr = ''
194
+ }
195
+ if (entry.exitCode !== null && clear) {
196
+ processes.delete(id)
197
+ }
198
+ return { ok: true, data: out }
199
+ }
200
+
201
+ function listProcesses(): {
202
+ ok: boolean
203
+ data: {
204
+ processes: Array<{
205
+ id: string
206
+ command: string
207
+ cwd: string
208
+ running: boolean
209
+ exit_code: number | null
210
+ started_at: number
211
+ }>
212
+ }
213
+ } {
214
+ return {
215
+ ok: true,
216
+ data: {
217
+ processes: [...processes.values()].map(p => ({
218
+ id: p.id,
219
+ command: p.command,
220
+ cwd: p.cwd,
221
+ running: p.exitCode === null,
222
+ exit_code: p.exitCode,
223
+ started_at: p.startedAt,
224
+ })),
225
+ },
226
+ }
227
+ }
228
+
229
+ function killProcess(
230
+ id: string,
231
+ signal: 'SIGTERM' | 'SIGKILL' | 'SIGINT',
232
+ ): { ok: boolean; data?: { id: string; killed: boolean }; error?: string } {
233
+ const entry = processes.get(id)
234
+ if (!entry) return { ok: false, error: `unknown process: ${id}` }
235
+ if (entry.exitCode !== null) {
236
+ processes.delete(id)
237
+ return { ok: true, data: { id, killed: false } }
238
+ }
239
+ try {
240
+ entry.proc.kill(signal)
241
+ return { ok: true, data: { id, killed: true } }
242
+ } catch (e) {
243
+ return { ok: false, error: (e as Error).message }
244
+ }
245
+ }
246
+
247
+ /** Kill all tracked processes. Called by chat.tsx on session exit. */
248
+ export function killAllProcesses(): void {
249
+ for (const entry of processes.values()) {
250
+ if (entry.exitCode !== null) continue
251
+ try {
252
+ entry.proc.kill('SIGTERM')
253
+ } catch {}
254
+ }
255
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { LocalBackend, type SandboxBackend, type ToolDef, redactEnv } from 'nebula-ai-core'
3
+ import { z } from 'zod'
4
+ import { type WorkingDirState, resolveCwd } from './cwd-state'
5
+
6
+ interface ShellToolDeps {
7
+ /**
8
+ * Working directory for spawned commands. Pass a `WorkingDirState` to share
9
+ * the cwd with `shell.cd` and other shell-class tools (production path), or
10
+ * a plain string for a fixed cwd (legacy + tests).
11
+ */
12
+ cwd: string | WorkingDirState
13
+ /** Default timeout in ms. */
14
+ defaultTimeoutMs?: number
15
+ /**
16
+ * Phase 9.5: sandbox backend wraps the spawn before exec. Default
17
+ * `LocalBackend` is a passthrough (today's behaviour). When sandbox.mode='os'
18
+ * on darwin, this becomes a sandbox-exec wrapper that denies writes outside
19
+ * agentDir + workspaceRoot + /tmp/nebula-* + /var/folders. Optional for
20
+ * back-compat: tests + legacy callers that don't supply it get LocalBackend.
21
+ */
22
+ sandbox?: SandboxBackend
23
+ }
24
+
25
+ const ShellSchema = z.object({
26
+ command: z
27
+ .string()
28
+ .min(1)
29
+ .describe('Shell command to run. Quoted, fully formed (e.g., `ls -la`).'),
30
+ timeout_ms: z
31
+ .number()
32
+ .int()
33
+ .positive()
34
+ .max(300_000)
35
+ .optional()
36
+ .describe('Override default 60s timeout.'),
37
+ })
38
+
39
+ export function makeShellRun(deps: ShellToolDeps): ToolDef<z.infer<typeof ShellSchema>> {
40
+ const sandbox = deps.sandbox ?? new LocalBackend()
41
+ const cwdState = resolveCwd(deps.cwd)
42
+ return {
43
+ name: 'shell.run',
44
+ description:
45
+ 'Run a shell command in the agent workspace. Subject to permission approval (mode `prompt` or `strict`). Wallet/API-key environment variables are redacted before launch. Captures stdout/stderr; killed on timeout.',
46
+ searchHint: 'shell run bash command execute subprocess',
47
+ schema: ShellSchema,
48
+ handler: async args => {
49
+ const cwd = cwdState.get()
50
+ const timeout = args.timeout_ms ?? deps.defaultTimeoutMs ?? 60_000
51
+ const { env: redactedEnv, removed } = redactEnv(process.env)
52
+ // Build an explicit /bin/sh -c <cmd> argv so the sandbox backend can
53
+ // wrap it without colliding with shell:true semantics. The wrapper may
54
+ // prepend e.g. sandbox-exec -p <profile>; spawn must see a real argv.
55
+ const wrapped = await sandbox.wrapSpawn({
56
+ command: '/bin/sh',
57
+ args: ['-c', args.command],
58
+ options: { cwd, env: redactedEnv },
59
+ })
60
+ return new Promise(resolve => {
61
+ const child = spawn(wrapped.command, wrapped.args, wrapped.options)
62
+ let stdout = ''
63
+ let stderr = ''
64
+ let timedOut = false
65
+ const timer = setTimeout(() => {
66
+ timedOut = true
67
+ child.kill('SIGKILL')
68
+ }, timeout)
69
+ child.stdout?.on('data', chunk => {
70
+ stdout += String(chunk)
71
+ if (stdout.length > 200_000) stdout = stdout.slice(-200_000)
72
+ })
73
+ child.stderr?.on('data', chunk => {
74
+ stderr += String(chunk)
75
+ if (stderr.length > 200_000) stderr = stderr.slice(-200_000)
76
+ })
77
+ child.on('close', code => {
78
+ clearTimeout(timer)
79
+ resolve({
80
+ ok: !timedOut && code === 0,
81
+ data: {
82
+ command: args.command,
83
+ code,
84
+ timedOut,
85
+ stdout: stdout.slice(-32_000),
86
+ stderr: stderr.slice(-32_000),
87
+ cwd,
88
+ redactedEnvVars: removed,
89
+ },
90
+ ...(timedOut
91
+ ? { error: `command timed out after ${timeout}ms` }
92
+ : code !== 0
93
+ ? { error: `exit code ${code}` }
94
+ : {}),
95
+ })
96
+ })
97
+ child.on('error', e => {
98
+ clearTimeout(timer)
99
+ resolve({ ok: false, error: e.message })
100
+ })
101
+ })
102
+ },
103
+ }
104
+ }
@@ -0,0 +1,126 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { type ToolDef, scanSkills } from 'nebula-ai-core'
3
+ import { z } from 'zod'
4
+
5
+ /**
6
+ * Phase 9.1 skills.manage. Persists per-skill on/off state into the user's
7
+ * nebula config (under `skills.disabled[]`). Re-scans the disk on every call so
8
+ * the user can install a new skill in another shell and the agent picks it up
9
+ * without restart.
10
+ */
11
+
12
+ interface SkillsManageDeps {
13
+ importsClaudeCode: boolean
14
+ /** Path to ~/.nebula/config.ts. Used to read+rewrite the disabled list. */
15
+ configPath: string
16
+ /** When set, the manage tool reports this list as the active disabled set. */
17
+ disabledRef?: { current: string[] }
18
+ nebulaSkillsRoot?: string
19
+ claudeSkillsRoot?: string
20
+ claudePluginsCacheRoot?: string
21
+ nebulaPluginsRoot?: string
22
+ }
23
+
24
+ const DISABLED_BLOCK_RE = /skills:\s*\{[^}]*disabled:\s*\[([\s\S]*?)\][^}]*\}/
25
+
26
+ const ManageSchema = z.object({
27
+ action: z
28
+ .enum(['list', 'enable', 'disable', 'refresh'])
29
+ .describe(
30
+ "'list' shows enabled+disabled skills; 'enable' / 'disable' flip a specific skill id; 'refresh' re-scans the disk.",
31
+ ),
32
+ id: z
33
+ .string()
34
+ .min(1)
35
+ .optional()
36
+ .describe('Skill id to enable/disable. Required for enable/disable actions.'),
37
+ })
38
+
39
+ export function makeSkillsManage(deps: SkillsManageDeps): ToolDef<z.infer<typeof ManageSchema>> {
40
+ return {
41
+ name: 'skills.manage',
42
+ description:
43
+ "Enable/disable specific skills, or list all skills with their on/off state. Disabled skills are persisted in ~/.nebula/config.ts so the next session honors the choice. Use 'refresh' to re-scan the disk after installing a new skill.",
44
+ searchHint: 'skills manage enable disable toggle',
45
+ schema: ManageSchema,
46
+ handler: async args => {
47
+ const all = await scanSkills({
48
+ importsClaudeCode: deps.importsClaudeCode,
49
+ nebulaSkillsRoot: deps.nebulaSkillsRoot,
50
+ nebulaPluginsRoot: deps.nebulaPluginsRoot,
51
+ claudeSkillsRoot: deps.claudeSkillsRoot,
52
+ claudePluginsCacheRoot: deps.claudePluginsCacheRoot,
53
+ })
54
+ const disabled = new Set(deps.disabledRef?.current ?? [])
55
+ if (args.action === 'list' || args.action === 'refresh') {
56
+ return {
57
+ ok: true,
58
+ data: {
59
+ total: all.length,
60
+ disabled: [...disabled].sort(),
61
+ skills: all
62
+ .map(s => ({
63
+ id: s.id,
64
+ source: s.source,
65
+ enabled: !disabled.has(s.id),
66
+ description: s.description,
67
+ }))
68
+ .sort((a, b) => a.id.localeCompare(b.id)),
69
+ },
70
+ }
71
+ }
72
+ if (!args.id) {
73
+ return { ok: false, error: 'id is required for enable/disable' }
74
+ }
75
+ const known = all.find(s => s.id === args.id)
76
+ if (!known) return { ok: false, error: `unknown skill: ${args.id}` }
77
+ if (args.action === 'enable') disabled.delete(args.id)
78
+ else disabled.add(args.id)
79
+ const next = [...disabled].sort()
80
+ try {
81
+ await persistDisabled(deps.configPath, next)
82
+ } catch (e) {
83
+ return { ok: false, error: `failed to update config: ${(e as Error).message}` }
84
+ }
85
+ if (deps.disabledRef) deps.disabledRef.current = next
86
+ return {
87
+ ok: true,
88
+ data: { id: args.id, action: args.action, disabled: next },
89
+ }
90
+ },
91
+ }
92
+ }
93
+
94
+ async function persistDisabled(configPath: string, disabled: readonly string[]): Promise<void> {
95
+ let raw: string
96
+ try {
97
+ raw = await readFile(configPath, 'utf8')
98
+ } catch {
99
+ raw = ''
100
+ }
101
+ const block = `skills: { disabled: [${disabled.map(s => `'${s.replace(/'/g, "\\'")}'`).join(', ')}] }`
102
+ if (raw.includes('skills:')) {
103
+ const next = raw.replace(DISABLED_BLOCK_RE, block)
104
+ await writeFile(configPath, next, 'utf8')
105
+ return
106
+ }
107
+ // Insert just before the closing `}` of the top-level config object. Works
108
+ // for both `defineConfig({...})` and `export default {...}` shapes.
109
+ const inserted = injectKey(raw, block)
110
+ if (inserted) {
111
+ await writeFile(configPath, inserted, 'utf8')
112
+ return
113
+ }
114
+ await writeFile(configPath, `${raw}\n// nebula added: ${block}\n`, 'utf8')
115
+ }
116
+
117
+ function injectKey(raw: string, block: string): string | null {
118
+ // Find the last `}` that closes the config object (handles both
119
+ // `}\n` and `})\n` endings; strips trailing whitespace).
120
+ const closer = raw.match(/\n}\)?\s*$/)
121
+ if (!closer) return null
122
+ const before = raw.slice(0, closer.index)
123
+ const after = raw.slice(closer.index!)
124
+ const sep = before.trimEnd().endsWith(',') ? '\n' : ',\n'
125
+ return `${before.trimEnd()}${sep} ${block}${after}`
126
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { type SkillRef, type ToolDef, coerceInt, scanSkills } from 'nebula-ai-core'
3
+ import { z } from 'zod'
4
+
5
+ /**
6
+ * Phase 9.1 skills surface. The scanner now lives in core (it's also used by
7
+ * the frozen-prefix builder + auto-trigger hook); these two tools are the
8
+ * brain-callable views.
9
+ */
10
+
11
+ interface SkillsToolDeps {
12
+ importsClaudeCode: boolean
13
+ /** Override the nebula skills root. Defaults to agentPaths.skills. */
14
+ nebulaSkillsRoot?: string
15
+ /** Override the Claude Code skills root. Default: $HOME/.claude/skills. */
16
+ claudeSkillsRoot?: string
17
+ /** Override the Claude Code plugins cache root. Default: $HOME/.claude/plugins/cache. */
18
+ claudePluginsCacheRoot?: string
19
+ /** Override the nebula plugins root. Defaults to agentPaths.plugins. */
20
+ nebulaPluginsRoot?: string
21
+ /** Disabled skill ids (skills.manage persists this set into config). */
22
+ disabled?: readonly string[]
23
+ }
24
+
25
+ async function discover(deps: SkillsToolDeps): Promise<SkillRef[]> {
26
+ const found = await scanSkills({
27
+ importsClaudeCode: deps.importsClaudeCode,
28
+ nebulaSkillsRoot: deps.nebulaSkillsRoot,
29
+ nebulaPluginsRoot: deps.nebulaPluginsRoot,
30
+ claudeSkillsRoot: deps.claudeSkillsRoot,
31
+ claudePluginsCacheRoot: deps.claudePluginsCacheRoot,
32
+ })
33
+ if (!deps.disabled || deps.disabled.length === 0) return found
34
+ const disabled = new Set(deps.disabled)
35
+ return found.filter(s => !disabled.has(s.id))
36
+ }
37
+
38
+ const ListSchema = z.object({
39
+ source: z.enum(['nebula', 'nebula-plugin', 'claude-code', 'claude-plugin', 'all']).optional(),
40
+ })
41
+
42
+ export function makeSkillsList(deps: SkillsToolDeps): ToolDef<z.infer<typeof ListSchema>> {
43
+ return {
44
+ name: 'skills.list',
45
+ description:
46
+ 'List skills from ~/.nebula/skills, ~/.nebula/plugins/<n>/skills, ~/.claude/skills, and ~/.claude/plugins/cache/<m>/<p>/<v>/skills (when imports.claudeCode). Returns id, name, description, source, path.',
47
+ searchHint: 'skills list catalog discover available',
48
+ schema: ListSchema,
49
+ handler: async args => {
50
+ const all = await discover(deps)
51
+ const filter = args.source ?? 'all'
52
+ const filtered = filter === 'all' ? all : all.filter(s => s.source === filter)
53
+ return {
54
+ ok: true,
55
+ data: {
56
+ skills: filtered.map(s => ({
57
+ id: s.id,
58
+ name: s.frontmatter.name ?? s.name,
59
+ description: s.description,
60
+ path: s.path,
61
+ source: s.source,
62
+ filePattern: s.frontmatter.filePattern ?? null,
63
+ bashPattern: s.frontmatter.bashPattern ?? null,
64
+ })),
65
+ },
66
+ }
67
+ },
68
+ }
69
+ }
70
+
71
+ const ViewSchema = z.object({
72
+ id: z.string().min(1).describe('Skill id from skills.list (e.g., "nebula:dogfood").'),
73
+ max_bytes: coerceInt.refine(n => n > 0 && n <= 200_000, 'max_bytes must be 1..200000').optional(),
74
+ })
75
+
76
+ export function makeSkillsView(deps: SkillsToolDeps): ToolDef<z.infer<typeof ViewSchema>> {
77
+ return {
78
+ name: 'skills.view',
79
+ description:
80
+ 'Read the full SKILL.md body for a skill identified by `skills.list`. Use to inline a skill before applying it.',
81
+ searchHint: 'skills view read body content',
82
+ schema: ViewSchema,
83
+ handler: async args => {
84
+ const all = await discover(deps)
85
+ const skill = all.find(s => s.id === args.id)
86
+ if (!skill) return { ok: false, error: `unknown skill: ${args.id}` }
87
+ try {
88
+ const buf = await readFile(skill.path)
89
+ const cap = args.max_bytes ?? 100_000
90
+ const truncated = buf.byteLength > cap
91
+ const text = buf.subarray(0, Math.min(buf.byteLength, cap)).toString('utf8')
92
+ return {
93
+ ok: true,
94
+ data: {
95
+ id: skill.id,
96
+ path: skill.path,
97
+ text,
98
+ bytes: buf.byteLength,
99
+ truncated,
100
+ },
101
+ }
102
+ } catch (e) {
103
+ return { ok: false, error: (e as Error).message }
104
+ }
105
+ },
106
+ }
107
+ }
package/src/todo.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { ToolDef } from 'nebula-ai-core'
2
+ import { z } from 'zod'
3
+
4
+ /**
5
+ * In-session task list. Brain uses this to plan multi-step work, similar to
6
+ * Claude Code's TodoWrite. State lives in-memory per session; when the
7
+ * process exits, the list is gone (this is intentional; persistent task
8
+ * state belongs in `memory.save` with `project` type).
9
+ */
10
+
11
+ interface TodoItem {
12
+ id: string
13
+ text: string
14
+ status: 'pending' | 'in_progress' | 'completed'
15
+ }
16
+
17
+ const TodoSchema = z.object({
18
+ action: z
19
+ .enum(['add', 'update', 'list', 'clear'])
20
+ .describe('add a task, update its status, list current tasks, or clear all.'),
21
+ id: z.string().optional().describe('Required for update; the task id returned from add.'),
22
+ text: z.string().optional().describe('Task description. Required for add.'),
23
+ status: z
24
+ .enum(['pending', 'in_progress', 'completed'])
25
+ .optional()
26
+ .describe('Required for update.'),
27
+ })
28
+
29
+ export function makeTodo(): ToolDef<z.infer<typeof TodoSchema>> {
30
+ const tasks: TodoItem[] = []
31
+ let next = 1
32
+ return {
33
+ name: 'todo',
34
+ description:
35
+ 'Manage an in-session task list. Use to plan multi-step work; the list is shown to the user via post-tool-call rendering. Tasks reset when chat exits.',
36
+ searchHint: 'todo task plan steps tracker',
37
+ schema: TodoSchema,
38
+ handler: args => {
39
+ if (args.action === 'add') {
40
+ if (!args.text) return { ok: false, error: 'text is required for add' }
41
+ const id = String(next++)
42
+ tasks.push({ id, text: args.text, status: 'pending' })
43
+ return { ok: true, data: { id, tasks } }
44
+ }
45
+ if (args.action === 'update') {
46
+ if (!args.id || !args.status) {
47
+ return { ok: false, error: 'id + status required for update' }
48
+ }
49
+ const idx = tasks.findIndex(t => t.id === args.id)
50
+ if (idx === -1) return { ok: false, error: `unknown task: ${args.id}` }
51
+ tasks[idx] = { ...tasks[idx]!, status: args.status }
52
+ return { ok: true, data: { tasks } }
53
+ }
54
+ if (args.action === 'clear') {
55
+ tasks.length = 0
56
+ return { ok: true, data: { tasks } }
57
+ }
58
+ return { ok: true, data: { tasks } }
59
+ },
60
+ }
61
+ }
62
+
63
+ const ClarifySchema = z.object({
64
+ question: z.string().min(3).describe('Question to ask the operator.'),
65
+ options: z
66
+ .array(z.string())
67
+ .optional()
68
+ .describe('Optional multiple-choice options for the operator.'),
69
+ })
70
+
71
+ /**
72
+ * `clarify` is the brain's escape hatch when it doesn't have enough info to
73
+ * proceed. Phase 9.0 implementation surfaces the question via the tool result
74
+ * (chat.tsx renders it as a system row); the operator can answer in their next
75
+ * message. A future phase will add a structured pause.
76
+ */
77
+ export function makeClarify(): ToolDef<z.infer<typeof ClarifySchema>> {
78
+ return {
79
+ name: 'clarify',
80
+ description:
81
+ 'Ask the operator a question and surface it inline. Use when the next step requires information the brain does not have. The result echoes the question back for display; the operator answers in their next message.',
82
+ searchHint: 'clarify ask question prompt operator',
83
+ schema: ClarifySchema,
84
+ handler: args => {
85
+ return {
86
+ ok: true,
87
+ data: { question: args.question, options: args.options ?? null },
88
+ }
89
+ },
90
+ }
91
+ }