acrylblends 0.0.0-stage → 0.3.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.
Files changed (144) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +118 -0
  3. package/dist/index.js +454 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/acryl-inspect/LICENSE +21 -0
  7. package/plugins/acryl-inspect/plugin.json +11 -0
  8. package/plugins/acryl-inspect/src/index.ts +3 -0
  9. package/plugins/acryl-inspect/src/report.ts +123 -0
  10. package/plugins/acryl-inspect/src/service.ts +66 -0
  11. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  12. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  13. package/plugins/acryl-inspect/upstream.json +18 -0
  14. package/plugins/acryl-node-server/LICENSE +21 -0
  15. package/plugins/acryl-node-server/plugin.json +12 -0
  16. package/plugins/acryl-node-server/src/index.ts +94 -0
  17. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  18. package/plugins/acryl-node-server/upstream.json +18 -0
  19. package/plugins/acryl-router/LICENSE +21 -0
  20. package/plugins/acryl-router/plugin.json +12 -0
  21. package/plugins/acryl-router/src/compose.ts +21 -0
  22. package/plugins/acryl-router/src/context.ts +59 -0
  23. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  24. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  25. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  26. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  27. package/plugins/acryl-router/src/http-error.ts +21 -0
  28. package/plugins/acryl-router/src/index.ts +6 -0
  29. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  30. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  31. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  32. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  33. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  34. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  35. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  36. package/plugins/acryl-router/src/pattern.ts +89 -0
  37. package/plugins/acryl-router/src/registrar.ts +90 -0
  38. package/plugins/acryl-router/src/service.ts +164 -0
  39. package/plugins/acryl-router/src/table.ts +156 -0
  40. package/plugins/acryl-router/src/types.ts +73 -0
  41. package/plugins/acryl-router/src/util.ts +23 -0
  42. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  43. package/plugins/acryl-router/tests/conformance.ts +170 -0
  44. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  45. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  46. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  47. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  48. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  49. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  50. package/plugins/acryl-router/upstream.json +20 -0
  51. package/plugins/acryl-router-http/LICENSE +21 -0
  52. package/plugins/acryl-router-http/plugin.json +11 -0
  53. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  54. package/plugins/acryl-router-http/src/index.ts +63 -0
  55. package/plugins/acryl-router-http/src/serve.ts +24 -0
  56. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  57. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  58. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  59. package/plugins/acryl-router-http/upstream.json +22 -0
  60. package/plugins/acryl-state/LICENSE +21 -0
  61. package/plugins/acryl-state/plugin.json +12 -0
  62. package/plugins/acryl-state/src/cell.ts +67 -0
  63. package/plugins/acryl-state/src/index.ts +3 -0
  64. package/plugins/acryl-state/src/react.ts +11 -0
  65. package/plugins/acryl-state/src/service.ts +101 -0
  66. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  67. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  68. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  69. package/plugins/acryl-state/upstream.json +20 -0
  70. package/plugins/agent/agent.ts +152 -0
  71. package/plugins/agent/index.ts +20 -0
  72. package/plugins/agent/memory.ts +24 -0
  73. package/plugins/agent/model.ts +79 -0
  74. package/plugins/agent/plugin.json +10 -0
  75. package/plugins/agent/tools.ts +50 -0
  76. package/plugins/builder/index.ts +193 -0
  77. package/plugins/builder/plugin.json +11 -0
  78. package/plugins/builder/script.ts +161 -0
  79. package/plugins/extensions/index.ts +755 -0
  80. package/plugins/extensions/ledger.ts +39 -0
  81. package/plugins/extensions/plugin.json +12 -0
  82. package/plugins/extensions/resolve.ts +30 -0
  83. package/plugins/extensions/stage.ts +44 -0
  84. package/plugins/instance/index.ts +167 -0
  85. package/plugins/instance/plugin.json +10 -0
  86. package/plugins/manifest/index.ts +635 -0
  87. package/plugins/manifest/lock.ts +94 -0
  88. package/plugins/manifest/plugin.json +13 -0
  89. package/plugins/manifest/upgrade-cli.ts +70 -0
  90. package/plugins/manifest/upgrade.ts +97 -0
  91. package/plugins/openai/index.ts +159 -0
  92. package/plugins/openai/plugin.json +11 -0
  93. package/plugins/openai/prompt.ts +35 -0
  94. package/plugins/sandbox/index.ts +150 -0
  95. package/plugins/sandbox/plugin.json +8 -0
  96. package/plugins/sandbox/runner.mjs +18 -0
  97. package/plugins/save/cli.ts +19 -0
  98. package/plugins/save/index.ts +55 -0
  99. package/plugins/save/plugin.json +12 -0
  100. package/plugins/settings/index.ts +270 -0
  101. package/plugins/settings/merge.ts +79 -0
  102. package/plugins/settings/plugin.json +13 -0
  103. package/plugins/tasks/index.ts +246 -0
  104. package/plugins/tasks/plugin.json +11 -0
  105. package/plugins/tasks/store.ts +86 -0
  106. package/plugins/tsconfig.json +27 -0
  107. package/templates/agent/Dockerfile +15 -0
  108. package/templates/agent/README.md +52 -0
  109. package/templates/agent/blend.yaml +56 -0
  110. package/templates/agent/dockerignore +3 -0
  111. package/templates/agent/gitignore +2 -0
  112. package/templates/agent/package.json +23 -0
  113. package/templates/agent/src/app.ts +98 -0
  114. package/templates/agent/src/main.ts +25 -0
  115. package/templates/agent/src/routes.ts +53 -0
  116. package/templates/agent/template.json +16 -0
  117. package/templates/agent/test/agent.test.ts +45 -0
  118. package/templates/agent/test/app.test.ts +46 -0
  119. package/templates/agent/test/builder.test.ts +194 -0
  120. package/templates/agent/test/model.test.ts +88 -0
  121. package/templates/agent/tsconfig.json +19 -0
  122. package/templates/blank/README.md +26 -0
  123. package/templates/blank/blend.yaml +23 -0
  124. package/templates/blank/gitignore +2 -0
  125. package/templates/blank/package.json +23 -0
  126. package/templates/blank/src/app.ts +61 -0
  127. package/templates/blank/src/main.ts +20 -0
  128. package/templates/blank/src/routes.ts +14 -0
  129. package/templates/blank/template.json +1 -0
  130. package/templates/blank/test/app.test.ts +56 -0
  131. package/templates/blank/tsconfig.json +19 -0
  132. package/templates/todo/README.md +27 -0
  133. package/templates/todo/blend.yaml +25 -0
  134. package/templates/todo/gitignore +2 -0
  135. package/templates/todo/package.json +23 -0
  136. package/templates/todo/src/app.ts +71 -0
  137. package/templates/todo/src/main.ts +19 -0
  138. package/templates/todo/src/page.ts +28 -0
  139. package/templates/todo/src/todo.ts +96 -0
  140. package/templates/todo/template.json +1 -0
  141. package/templates/todo/test/todo.test.ts +145 -0
  142. package/templates/todo/tsconfig.json +19 -0
  143. package/templates/todo/upstream.json +12 -0
  144. package/README.md +0 -3
@@ -0,0 +1,50 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+
3
+ /** One tool: what it does (for the model) and how to run it. */
4
+ export interface Tool {
5
+ readonly description: string
6
+ run(input: unknown): unknown | Promise<unknown>
7
+ }
8
+
9
+ /** The tool registry. A plugin registers its tools inside `ctx.effect`, so they leave with it. */
10
+ export interface Tools {
11
+ /** Register a tool. Returns the function that removes it. */
12
+ register(name: string, tool: Tool): () => void
13
+ list(): Array<{ name: string; description: string }>
14
+ call(name: string, input: unknown): Promise<unknown>
15
+ }
16
+
17
+ declare module '@deepseek-ai/cordis' {
18
+ interface Context {
19
+ tools: Tools
20
+ }
21
+ }
22
+
23
+ /** Provides the registry, with one example tool (`add`). */
24
+ export function tools(ctx: Context) {
25
+ const table = new Map<string, Tool>()
26
+ const registry: Tools = {
27
+ register(name, tool) {
28
+ if (table.has(name)) throw new Error(`tool "${name}" is already registered`)
29
+ table.set(name, tool)
30
+ return () => void table.delete(name)
31
+ },
32
+ list: () =>
33
+ [...table.entries()].map(([name, tool]) => ({ name, description: tool.description })),
34
+ async call(name, input) {
35
+ const tool = table.get(name)
36
+ if (!tool) throw new Error(`unknown tool "${name}"`)
37
+ return await tool.run(input)
38
+ },
39
+ }
40
+ ctx.provide('tools', registry)
41
+ ctx.effect(() =>
42
+ registry.register('add', {
43
+ description: 'Add two numbers: input [a, b].',
44
+ run: (input) => {
45
+ const [a = 0, b = 0] = Array.isArray(input) ? (input as number[]) : []
46
+ return a + b
47
+ },
48
+ }),
49
+ )
50
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The builder: the agent's tools for extending the app it runs in.
3
+ *
4
+ * Extracted from ACRYL's agent tools (`acryl_install_plugin` and its extension docs): the agent writes a plugin into the app's own
5
+ * extensions folder, installs it, and gets back the real result (`active`, `pending` with what it waits for, or the error, with the
6
+ * previous version restored). Writes are confined to that folder. The tools register in `ctx.effect`, so they leave with the builder.
7
+ */
8
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import { scripted, type Tools } from '../agent/index.ts'
12
+ import { NAME } from '../extensions/index.ts'
13
+ import type {} from '../instance/index.ts'
14
+ import { builderRules } from './script.ts'
15
+
16
+ export interface BuilderConfig {
17
+ /** Where the agent writes extensions. Defaults to `<project>/extensions`. Must be one of the extensions sources. */
18
+ dir?: string
19
+ /** Where `package_plugin` writes standalone packages. Defaults to `<project>/plugin-packages`. */
20
+ packagesDir?: string
21
+ }
22
+
23
+ /** The package.json of a standalone plugin package: the host framework is a peer, never a second installed copy. */
24
+ export function packageManifest(name: string, description: string): Record<string, unknown> {
25
+ return {
26
+ name,
27
+ version: '0.1.0',
28
+ description,
29
+ type: 'module',
30
+ exports: { '.': './index.ts', './package.json': './package.json' },
31
+ files: ['*.ts', '**/*.ts', 'README.md'],
32
+ // ACRYL measured it: a host package listed under dependencies brings a second copy of the framework, and its services stop
33
+ // matching after a restart. A peer names the version and installs nothing.
34
+ peerDependencies: { '@deepseek-ai/cordis': '4.0.4' },
35
+ keywords: ['cordis', 'cordis-plugin', 'acrylblends'],
36
+ }
37
+ }
38
+
39
+ const EXTENSIONS = new Set(['.ts', '.js', '.mjs', '.json', '.md'])
40
+ const MAX_BYTES = 256 * 1024
41
+
42
+ export class BuilderError extends Error {
43
+ override name = 'BuilderError'
44
+ }
45
+
46
+ function pluginName(input: unknown): string {
47
+ const name = (input as { name?: unknown } | undefined)?.name
48
+ if (typeof name !== 'string' || !NAME.test(name))
49
+ throw new BuilderError('name must be lowercase letters, digits and dashes')
50
+ return name
51
+ }
52
+
53
+ /** A relative file path inside a plugin folder, or an error. */
54
+ function safeFile(path: string): string {
55
+ const clean = normalize(path)
56
+ if (isAbsolute(path) || clean.startsWith('..') || clean.split(sep).includes('..'))
57
+ throw new BuilderError(`"${path}" must be a path inside the plugin folder`)
58
+ if (!EXTENSIONS.has(extname(clean)))
59
+ throw new BuilderError(`"${path}": only ${[...EXTENSIONS].join(', ')} files may be written`)
60
+ return clean
61
+ }
62
+
63
+ export const builder = {
64
+ name: 'builder',
65
+ inject: ['extensions', 'tools', 'appInstance'],
66
+ apply(ctx: Context, config: BuilderConfig) {
67
+ const dir = resolve(config?.dir ?? join(ctx.appInstance.root, 'extensions'))
68
+ if (!ctx.extensions.sources.some((source) => resolve(source.dir) === dir)) {
69
+ throw new BuilderError(
70
+ `the builder writes into ${dir}, which is not an extensions source; add it to the extensions plugin's sources`,
71
+ )
72
+ }
73
+ const folder = (name: string) => join(dir, name)
74
+ const tools: Tools = ctx.tools
75
+ const register = (name: string, description: string, run: (input: unknown) => unknown) =>
76
+ ctx.effect(() => tools.register(name, { description, run }))
77
+
78
+ register(
79
+ 'list_plugins',
80
+ 'List installed extensions with their state, and folders not installed yet.',
81
+ () => ({
82
+ installed: ctx.extensions.list(),
83
+ notInstalled: ctx.extensions.pending(),
84
+ }),
85
+ )
86
+
87
+ register(
88
+ 'read_plugin',
89
+ 'Read a file of an extension: input { name, file? } (default index.ts).',
90
+ (input) => {
91
+ const name = pluginName(input)
92
+ const file = safeFile((input as { file?: string }).file ?? 'index.ts')
93
+ const path = join(folder(name), file)
94
+ if (!existsSync(path)) throw new BuilderError(`${name}/${file} does not exist`)
95
+ return { name, file, text: readFileSync(path, 'utf8') }
96
+ },
97
+ )
98
+
99
+ register(
100
+ 'write_plugin',
101
+ 'Write files of an extension: input { name, files: { "index.ts": "..." } }. The folder is created; other files are kept.',
102
+ (input) => {
103
+ const name = pluginName(input)
104
+ const files = (input as { files?: unknown }).files
105
+ if (!files || typeof files !== 'object')
106
+ throw new BuilderError('files must be an object of path to text')
107
+ const entries = Object.entries(files as Record<string, unknown>)
108
+ const bytes = entries.reduce(
109
+ (total, [, text]) => total + Buffer.byteLength(String(text)),
110
+ 0,
111
+ )
112
+ if (entries.length === 0) throw new BuilderError('files is empty')
113
+ if (bytes > MAX_BYTES)
114
+ throw new BuilderError(`files are ${bytes} bytes; the limit is ${MAX_BYTES}`)
115
+ const written: string[] = []
116
+ for (const [path, text] of entries) {
117
+ if (typeof text !== 'string') throw new BuilderError(`${path}: the content must be text`)
118
+ const file = safeFile(path)
119
+ const target = join(folder(name), file)
120
+ if (relative(folder(name), target).startsWith('..'))
121
+ throw new BuilderError(`"${path}" leaves the plugin folder`)
122
+ mkdirSync(dirname(target), { recursive: true })
123
+ writeFileSync(target, text)
124
+ written.push(file)
125
+ }
126
+ return { ok: true, name, dir: folder(name), written }
127
+ },
128
+ )
129
+
130
+ register(
131
+ 'install_plugin',
132
+ 'Install or update an extension from its folder: input { name }. Returns its real state.',
133
+ (input) => ctx.extensions.install(folder(pluginName(input))),
134
+ )
135
+
136
+ register(
137
+ 'remove_plugin',
138
+ 'Remove an installed extension: input { name, deleteSource? }. deleteSource also deletes its folder.',
139
+ async (input) => {
140
+ const name = pluginName(input)
141
+ const result = await ctx.extensions.remove(name)
142
+ if (
143
+ (input as { deleteSource?: unknown }).deleteSource === true &&
144
+ existsSync(folder(name))
145
+ ) {
146
+ rmSync(folder(name), { recursive: true, force: true })
147
+ }
148
+ return result
149
+ },
150
+ )
151
+
152
+ const packagesDir = resolve(
153
+ config?.packagesDir ?? join(ctx.appInstance.root, 'plugin-packages'),
154
+ )
155
+ register(
156
+ 'package_plugin',
157
+ 'Turn an extension into a standalone plugin package other apps can use: input { name, description? }. Returns its folder.',
158
+ (input) => {
159
+ const name = pluginName(input)
160
+ if (
161
+ !existsSync(join(folder(name), 'index.ts')) &&
162
+ !existsSync(join(folder(name), 'index.js'))
163
+ ) {
164
+ throw new BuilderError(`${name} has no index.ts in ${folder(name)}; write it first`)
165
+ }
166
+ const description = String(
167
+ (input as { description?: unknown }).description ?? `The ${name} Cordis plugin.`,
168
+ )
169
+ const target = join(packagesDir, name)
170
+ rmSync(target, { recursive: true, force: true })
171
+ mkdirSync(packagesDir, { recursive: true })
172
+ cpSync(folder(name), target, {
173
+ recursive: true,
174
+ filter: (path) => !path.includes('node_modules'),
175
+ })
176
+ writeFileSync(
177
+ join(target, 'package.json'),
178
+ `${JSON.stringify(packageManifest(name, description), null, 2)}\n`,
179
+ )
180
+ if (!existsSync(join(target, 'README.md'))) {
181
+ writeFileSync(
182
+ join(target, 'README.md'),
183
+ `# ${name}\n\n${description}\n\nA Cordis plugin. In an ACRYLBLENDS app: \`acrylblends add <this folder>\`.\n`,
184
+ )
185
+ }
186
+ return { ok: true, name, dir: target }
187
+ },
188
+ )
189
+
190
+ const model = scripted(ctx.get('model'))
191
+ if (model) ctx.effect(() => model.teach(builderRules))
192
+ },
193
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "builder",
3
+ "description": "The agent's tools for building the app it runs in: write, install, read, list and remove extensions, with real results.",
4
+ "requires": ["agent", "extensions", "sandbox"],
5
+ "inject": ["extensions", "tools", "appInstance"],
6
+ "provides": [],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "mount it after agent and extensions: root.plugin(builder, { dir: join(root, 'extensions') }); dir must be an extensions source"
11
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The builder's commands for the offline scripted model, so the whole build-install-update-remove loop runs without a network.
3
+ * A real model reads the tool descriptions instead and decides for itself.
4
+ *
5
+ * create route <name> <path> <text...> write a plugin that answers GET <path> with { text }, then install it
6
+ * update route <name> <path> <text...> the same, over the existing plugin
7
+ * break plugin <name> write a version whose apply throws, then try to install it
8
+ * remove plugin <name> remove it and delete its folder
9
+ * create tool <name> <factor> write a sandboxed tool that multiplies input.n by factor (its example is its check), install it
10
+ * run tool <name> <n> call an installed tool
11
+ * spy tool <name> write a tool that tries to read /etc/hosts, to show the sandbox refusing it
12
+ * list plugins list installed extensions
13
+ * package plugin <name> make it a standalone package other apps can add
14
+ */
15
+ import type { Rule, Step } from '../agent/index.ts'
16
+
17
+ const PATH = /^\/[a-z0-9/-]*$/
18
+
19
+ export function routeSource(name: string, path: string, text: string): string {
20
+ return `import type { Context } from '@deepseek-ai/cordis'
21
+
22
+ /** Written by the builder: answers GET ${path}, and checks that it does before the install counts. */
23
+ export const name = '${name}'
24
+ export const inject = ['router']
25
+
26
+ interface Router {
27
+ get(path: string, handler: (c: { json(body: unknown, status?: number): Response }) => Response): unknown
28
+ request(path: string): Promise<Response>
29
+ }
30
+ export function apply(ctx: Context) {
31
+ // A route registered through this plugin's context is removed when the plugin is unloaded.
32
+ (ctx as unknown as { router: Router }).router.get(${JSON.stringify(path)}, (c) => c.json({ text: ${JSON.stringify(text)} }))
33
+ }
34
+
35
+ export async function check(ctx: Context) {
36
+ const reply = await (ctx.get('router') as Router).request(${JSON.stringify(path)})
37
+ if (!reply.ok) throw new Error(\`GET ${path} answered \${reply.status}\`)
38
+ }
39
+ `
40
+ }
41
+
42
+ export function brokenSource(name: string): string {
43
+ return `export const name = '${name}'
44
+
45
+ export function apply() {
46
+ throw new Error('${name} refuses to start')
47
+ }
48
+ `
49
+ }
50
+
51
+ function summary(result: unknown): string {
52
+ const waiting = (result as { stage?: string; name?: string } | undefined)?.stage === 'approval'
53
+ if (waiting) return `${(result as { name: string }).name}: waiting for the owner's approval.`
54
+ const answer = result as {
55
+ ok?: boolean
56
+ name?: string
57
+ action?: string
58
+ state?: string
59
+ errors?: string[]
60
+ restoredPrevious?: boolean
61
+ }
62
+ if (answer?.ok === true && answer.action)
63
+ return `${answer.name}: ${answer.action}, ${answer.state}.`
64
+ if (answer?.ok === true) return `${answer.name}: done.`
65
+ const restored =
66
+ answer?.restoredPrevious === undefined
67
+ ? ''
68
+ : ` Previous version restored: ${String(answer.restoredPrevious)}.`
69
+ return `Refused: ${(answer?.errors ?? ['unknown error']).join('; ')}.${restored}`
70
+ }
71
+
72
+ /** Write, then install, then report. */
73
+ function writeThenInstall(
74
+ step: number,
75
+ toolResult: unknown,
76
+ name: string,
77
+ files: Record<string, string>,
78
+ ): Step {
79
+ if (step === 0) return { tool: 'write_plugin', input: { name, files } }
80
+ if (step === 1) {
81
+ const written = toolResult as { ok?: boolean; error?: string }
82
+ if (written?.ok !== true)
83
+ return { text: `Could not write ${name}: ${written?.error ?? 'unknown error'}` }
84
+ return { tool: 'install_plugin', input: { name } }
85
+ }
86
+ return { text: summary(toolResult) }
87
+ }
88
+
89
+ export function toolFiles(factor: number): Record<string, string> {
90
+ return {
91
+ 'index.ts': `/** Written by the builder: multiplies input.n by ${factor}. Runs only in the sandbox. */\nexport function run(input: { n: number }) {\n return { n: input.n * ${factor} }\n}\n`,
92
+ 'tool.json': JSON.stringify({
93
+ description: `Multiply a number by ${factor}: input { n }.`,
94
+ examples: [{ input: { n: 2 }, output: { n: 2 * factor } }],
95
+ }),
96
+ }
97
+ }
98
+
99
+ export function spyFiles(): Record<string, string> {
100
+ return {
101
+ 'index.ts':
102
+ "import { readFileSync } from 'node:fs'\nexport function run() {\n return readFileSync('/etc/hosts', 'utf8').length\n}\n",
103
+ 'tool.json': JSON.stringify({
104
+ description: 'Reads a file it should not.',
105
+ examples: [{ input: {}, output: 1 }],
106
+ }),
107
+ }
108
+ }
109
+
110
+ export const builderRules: Rule = ({ question, step, toolResult }) => {
111
+ const words = question.trim().split(/\s+/)
112
+ const [verb, noun, name, path, ...text] = words
113
+ if (
114
+ (verb === 'create' || verb === 'update') &&
115
+ noun === 'route' &&
116
+ name &&
117
+ path &&
118
+ PATH.test(path)
119
+ ) {
120
+ return writeThenInstall(step, toolResult, name, {
121
+ 'index.ts': routeSource(name, path, text.join(' ') || name),
122
+ })
123
+ }
124
+ if (verb === 'create' && noun === 'tool' && name && path && Number.isFinite(Number(path))) {
125
+ return writeThenInstall(step, toolResult, name, toolFiles(Number(path)))
126
+ }
127
+ if (verb === 'spy' && noun === 'tool' && name)
128
+ return writeThenInstall(step, toolResult, name, spyFiles())
129
+ if (verb === 'run' && noun === 'tool' && name && path && Number.isFinite(Number(path))) {
130
+ return step === 0
131
+ ? { tool: name, input: { n: Number(path) } }
132
+ : { text: JSON.stringify(toolResult) }
133
+ }
134
+ if (verb === 'break' && noun === 'plugin' && name)
135
+ return writeThenInstall(step, toolResult, name, { 'index.ts': brokenSource(name) })
136
+ if (verb === 'remove' && noun === 'plugin' && name) {
137
+ return step === 0
138
+ ? { tool: 'remove_plugin', input: { name, deleteSource: true } }
139
+ : { text: summary(toolResult) }
140
+ }
141
+ if (verb === 'package' && noun === 'plugin' && name) {
142
+ if (step === 0) return { tool: 'package_plugin', input: { name } }
143
+ const packaged = toolResult as { ok?: boolean; dir?: string; error?: string }
144
+ return {
145
+ text: packaged?.ok
146
+ ? `${name}: packaged in ${packaged.dir}.`
147
+ : `Could not package ${name}: ${packaged?.error ?? 'unknown error'}`,
148
+ }
149
+ }
150
+ if (verb === 'list' && noun === 'plugins') {
151
+ if (step === 0) return { tool: 'list_plugins', input: {} }
152
+ const installed =
153
+ (toolResult as { installed?: Array<{ name: string; state: string }> })?.installed ?? []
154
+ return {
155
+ text: installed.length
156
+ ? installed.map((entry) => `${entry.name} (${entry.state})`).join(', ')
157
+ : 'No plugins installed.',
158
+ }
159
+ }
160
+ return undefined
161
+ }