acrylblends 0.0.0-stage → 0.2.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 (74) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +117 -0
  3. package/dist/index.js +434 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/agent/agent.ts +152 -0
  7. package/plugins/agent/index.ts +20 -0
  8. package/plugins/agent/memory.ts +24 -0
  9. package/plugins/agent/model.ts +79 -0
  10. package/plugins/agent/plugin.json +10 -0
  11. package/plugins/agent/tools.ts +50 -0
  12. package/plugins/builder/index.ts +193 -0
  13. package/plugins/builder/plugin.json +11 -0
  14. package/plugins/builder/script.ts +162 -0
  15. package/plugins/events/index.ts +42 -0
  16. package/plugins/events/plugin.json +9 -0
  17. package/plugins/extensions/index.ts +738 -0
  18. package/plugins/extensions/ledger.ts +39 -0
  19. package/plugins/extensions/plugin.json +12 -0
  20. package/plugins/extensions/resolve.ts +30 -0
  21. package/plugins/extensions/stage.ts +44 -0
  22. package/plugins/http/index.ts +64 -0
  23. package/plugins/http/plugin.json +10 -0
  24. package/plugins/instance/index.ts +167 -0
  25. package/plugins/instance/plugin.json +10 -0
  26. package/plugins/manifest/index.ts +635 -0
  27. package/plugins/manifest/lock.ts +94 -0
  28. package/plugins/manifest/plugin.json +13 -0
  29. package/plugins/manifest/upgrade-cli.ts +70 -0
  30. package/plugins/manifest/upgrade.ts +97 -0
  31. package/plugins/openai/index.ts +159 -0
  32. package/plugins/openai/plugin.json +11 -0
  33. package/plugins/openai/prompt.ts +31 -0
  34. package/plugins/sandbox/index.ts +150 -0
  35. package/plugins/sandbox/plugin.json +8 -0
  36. package/plugins/sandbox/runner.mjs +18 -0
  37. package/plugins/save/cli.ts +19 -0
  38. package/plugins/save/index.ts +55 -0
  39. package/plugins/save/plugin.json +12 -0
  40. package/plugins/server/index.ts +138 -0
  41. package/plugins/server/plugin.json +10 -0
  42. package/plugins/settings/index.ts +270 -0
  43. package/plugins/settings/merge.ts +79 -0
  44. package/plugins/settings/plugin.json +13 -0
  45. package/plugins/tasks/index.ts +232 -0
  46. package/plugins/tasks/plugin.json +11 -0
  47. package/plugins/tasks/store.ts +86 -0
  48. package/plugins/tsconfig.json +19 -0
  49. package/templates/agent/Dockerfile +15 -0
  50. package/templates/agent/README.md +52 -0
  51. package/templates/agent/blend.yaml +58 -0
  52. package/templates/agent/dockerignore +3 -0
  53. package/templates/agent/gitignore +2 -0
  54. package/templates/agent/package.json +23 -0
  55. package/templates/agent/src/app.ts +98 -0
  56. package/templates/agent/src/main.ts +25 -0
  57. package/templates/agent/src/routes.ts +62 -0
  58. package/templates/agent/template.json +16 -0
  59. package/templates/agent/test/agent.test.ts +45 -0
  60. package/templates/agent/test/app.test.ts +46 -0
  61. package/templates/agent/test/builder.test.ts +186 -0
  62. package/templates/agent/test/model.test.ts +89 -0
  63. package/templates/agent/tsconfig.json +19 -0
  64. package/templates/app/README.md +26 -0
  65. package/templates/app/blend.yaml +23 -0
  66. package/templates/app/gitignore +2 -0
  67. package/templates/app/package.json +23 -0
  68. package/templates/app/src/app.ts +61 -0
  69. package/templates/app/src/main.ts +20 -0
  70. package/templates/app/src/routes.ts +14 -0
  71. package/templates/app/template.json +1 -0
  72. package/templates/app/test/app.test.ts +56 -0
  73. package/templates/app/tsconfig.json +19 -0
  74. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Scaffold Cordis apps: acrylblends init.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "acrylblends": "./bin/acrylblends.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "dist",
22
+ "templates",
23
+ "plugins"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsdown",
27
+ "clean": "rm -rf dist",
28
+ "test": "vitest run",
29
+ "prepack": "pnpm run build && node scripts-pack.mjs copy",
30
+ "postpack": "node scripts-pack.mjs remove"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.19.0"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/acrylblends/acrylblends.git",
38
+ "directory": "packages/cli"
39
+ },
40
+ "homepage": "https://acrylblends.github.io",
41
+ "keywords": [
42
+ "cordis",
43
+ "scaffold"
44
+ ],
45
+ "private": false,
46
+ "devDependencies": {
47
+ "@deepseek-ai/cordis": "4.0.4"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
@@ -0,0 +1,152 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from './memory.ts'
3
+ import type {} from './model.ts'
4
+ import type {} from './tools.ts'
5
+
6
+ /** One tool call made during a run. */
7
+ export interface ToolCall {
8
+ readonly tool: string
9
+ readonly input: unknown
10
+ readonly result?: unknown
11
+ readonly error?: string
12
+ /** The thinking model's reasoning for this call, kept so the next step can send it back. */
13
+ readonly reasoning?: string
14
+ }
15
+
16
+ /** How a run ended, with every tool call it made. `task` is its durable record's id when the app records tasks. */
17
+ export type RunResult = (
18
+ | { readonly status: 'completed'; readonly output: string }
19
+ | { readonly status: 'budget_exceeded' }
20
+ | { readonly status: 'cancelled' }
21
+ ) & { readonly steps: number; readonly trace: readonly ToolCall[]; readonly task?: string }
22
+
23
+ export interface Agent {
24
+ run(question: string): Promise<RunResult>
25
+ }
26
+
27
+ /** The step budget of one run, provided inside the run's own fiber. */
28
+ export interface Budget {
29
+ steps: number
30
+ readonly limit: number
31
+ }
32
+
33
+ export interface AgentConfig {
34
+ /** Steps per run before it stops. */
35
+ steps?: number
36
+ }
37
+
38
+ /**
39
+ * The part of a task recorder the agent uses. Optional: when an app mounts the `tasks` plugin, each run is a durable task and each
40
+ * tool call a task it owns; without it, runs work the same and leave no record.
41
+ */
42
+ interface TaskRecorder {
43
+ start(
44
+ kind: string,
45
+ input: unknown,
46
+ options?: { parent?: string },
47
+ ): {
48
+ readonly id: string
49
+ readonly signal: AbortSignal
50
+ progress(detail: unknown): void
51
+ complete(result: unknown, detail?: unknown): void
52
+ fail(error: unknown, detail?: unknown): void
53
+ }
54
+ }
55
+
56
+ declare module '@deepseek-ai/cordis' {
57
+ interface Context {
58
+ agent: Agent
59
+ budget: Budget
60
+ }
61
+ }
62
+
63
+ const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
64
+
65
+ /**
66
+ * The agent. Every run is its own child fiber that holds the run's budget and is disposed when the run ends, however it ends: a
67
+ * scope per task, written with plain Cordis plugins. A tool that throws does not end the run: the model sees the error as the result.
68
+ * When the app records tasks, the run's record is written before the answer is returned, and cancelling it stops the run.
69
+ */
70
+ export const agent = {
71
+ name: 'agent',
72
+ inject: ['model', 'tools', 'memory'],
73
+ apply(ctx: Context, config?: AgentConfig) {
74
+ const limit = config?.steps ?? 6
75
+ let runs = 0
76
+ ctx.provide('agent', {
77
+ async run(question) {
78
+ runs += 1
79
+ const trace: ToolCall[] = []
80
+ const recorder = (ctx.get as (name: string) => unknown)('tasks') as TaskRecorder | undefined
81
+ const record = recorder?.start('agent-run', { question })
82
+ // Assigned inside the run's fiber; `as` keeps TypeScript from narrowing it to this first value.
83
+ let result = { status: 'budget_exceeded', steps: 0, trace } as RunResult
84
+ const task = ctx.plugin({
85
+ name: `task-${runs}`,
86
+ inject: ['model', 'tools', 'memory'],
87
+ async apply(scope: Context) {
88
+ scope.provide('budget', { steps: 0, limit } satisfies Budget)
89
+ const budget = scope.budget
90
+ let toolResult: unknown
91
+ while (budget.steps < budget.limit) {
92
+ if (record?.signal.aborted) {
93
+ result = { status: 'cancelled', steps: budget.steps, trace }
94
+ return
95
+ }
96
+ const step = await scope.model.complete({
97
+ question,
98
+ step: budget.steps,
99
+ toolResult,
100
+ trace: [...trace],
101
+ tools: scope.tools.list(),
102
+ })
103
+ budget.steps += 1
104
+ if (step.tool === undefined) {
105
+ scope.memory.remember(`${question} => ${step.text}`)
106
+ result = { status: 'completed', output: step.text, steps: budget.steps, trace }
107
+ return
108
+ }
109
+ const call = record
110
+ ? recorder?.start(`tool:${step.tool}`, step.input, { parent: record.id })
111
+ : undefined
112
+ try {
113
+ toolResult = await scope.tools.call(step.tool, step.input)
114
+ trace.push({
115
+ tool: step.tool,
116
+ input: step.input,
117
+ result: toolResult,
118
+ ...(step.reasoning !== undefined ? { reasoning: step.reasoning } : {}),
119
+ })
120
+ call?.complete(toolResult)
121
+ } catch (error) {
122
+ toolResult = { error: message(error) }
123
+ trace.push({
124
+ tool: step.tool,
125
+ input: step.input,
126
+ error: message(error),
127
+ ...(step.reasoning !== undefined ? { reasoning: step.reasoning } : {}),
128
+ })
129
+ call?.fail(error)
130
+ }
131
+ record?.progress({ steps: budget.steps, trace })
132
+ }
133
+ result = { status: 'budget_exceeded', steps: budget.steps, trace }
134
+ },
135
+ })
136
+ try {
137
+ await task.await()
138
+ } catch (error) {
139
+ record?.fail(error, { trace })
140
+ throw error
141
+ } finally {
142
+ await task.dispose()
143
+ }
144
+ if (result.status === 'completed')
145
+ record?.complete(result.output, { steps: result.steps, trace })
146
+ else if (result.status === 'budget_exceeded')
147
+ record?.fail('the run used its whole step budget', { steps: result.steps, trace })
148
+ return record ? { ...result, task: record.id } : result
149
+ },
150
+ } satisfies Agent)
151
+ },
152
+ }
@@ -0,0 +1,20 @@
1
+ /** The offline agent: a scripted model, a tool registry, in-process memory, and one child fiber per run. */
2
+ export {
3
+ type Agent,
4
+ type AgentConfig,
5
+ agent,
6
+ type Budget,
7
+ type RunResult,
8
+ type ToolCall,
9
+ } from './agent.ts'
10
+ export { type Memory, memory } from './memory.ts'
11
+ export {
12
+ type Model,
13
+ type ModelInput,
14
+ model,
15
+ type Rule,
16
+ type ScriptedModel,
17
+ type Step,
18
+ scripted,
19
+ } from './model.ts'
20
+ export { type Tool, type Tools, tools } from './tools.ts'
@@ -0,0 +1,24 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+
3
+ /** Long-term memory for the agent. */
4
+ export interface Memory {
5
+ remember(text: string): void
6
+ recall(): string[]
7
+ size(): number
8
+ }
9
+
10
+ declare module '@deepseek-ai/cordis' {
11
+ interface Context {
12
+ memory: Memory
13
+ }
14
+ }
15
+
16
+ /** In-process memory. Replace it with a plugin backed by storage when you need persistence. */
17
+ export function memory(ctx: Context) {
18
+ const items: string[] = []
19
+ ctx.provide('memory', {
20
+ remember: (text) => void items.push(text),
21
+ recall: () => [...items],
22
+ size: () => items.length,
23
+ } satisfies Memory)
24
+ }
@@ -0,0 +1,79 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+
3
+ /** One model step: final text, or a tool to call with its input. `reasoning` is a thinking model's reasoning for the call; it must be given back on the next step, so the run keeps it. */
4
+ export type Step =
5
+ | { readonly text: string; readonly tool?: undefined }
6
+ | { readonly tool: string; readonly input: unknown; readonly reasoning?: string }
7
+
8
+ /** What a model sees at each step of a run. */
9
+ export interface ModelInput {
10
+ readonly question: string
11
+ /** 0 for the first step of a run. */
12
+ readonly step: number
13
+ /** The previous tool's result, if the previous step called one. */
14
+ readonly toolResult?: unknown
15
+ /** Every tool call of this run so far, in order: a real model rebuilds the conversation from it. */
16
+ readonly trace?: ReadonlyArray<{
17
+ readonly tool: string
18
+ readonly input: unknown
19
+ readonly result?: unknown
20
+ readonly error?: string
21
+ readonly reasoning?: string
22
+ }>
23
+ /** The tools the model may call. */
24
+ readonly tools?: ReadonlyArray<{ readonly name: string; readonly description: string }>
25
+ }
26
+
27
+ /** What the agent needs from a model. Replace this plugin with a real provider when you have one. */
28
+ export interface Model {
29
+ complete(input: ModelInput): Promise<Step>
30
+ }
31
+
32
+ /** A rule of the scripted model: a step for the input, or `undefined` when it does not apply. */
33
+ export type Rule = (input: ModelInput) => Step | undefined
34
+
35
+ /** The deterministic model. Other plugins teach it rules for their commands, so whole flows are tested without a network. */
36
+ export interface ScriptedModel extends Model {
37
+ /** Add a rule, tried before the built-in ones. Returns the function that removes it. */
38
+ teach(rule: Rule): () => void
39
+ }
40
+
41
+ declare module '@deepseek-ai/cordis' {
42
+ interface Context {
43
+ model: Model
44
+ }
45
+ }
46
+
47
+ const builtIn: Rule[] = [
48
+ ({ toolResult, step }) =>
49
+ step > 0 ? { text: `The answer is ${String(toolResult)}.` } : undefined,
50
+ ({ question }) => {
51
+ const sum = /^add\s+(-?\d+)\s+(-?\d+)$/.exec(question.trim())
52
+ return sum ? { tool: 'add', input: [Number(sum[1]), Number(sum[2])] } : undefined
53
+ },
54
+ ]
55
+
56
+ /** A deterministic model: no keys, no network, the same answer every time. */
57
+ export function model(ctx: Context) {
58
+ const taught: Rule[] = []
59
+ ctx.provide('model', {
60
+ teach(rule) {
61
+ taught.push(rule)
62
+ return () => void taught.splice(taught.indexOf(rule), 1)
63
+ },
64
+ complete(input) {
65
+ for (const rule of [...taught, ...builtIn]) {
66
+ const step = rule(input)
67
+ if (step) return Promise.resolve(step)
68
+ }
69
+ return Promise.resolve({ text: `You said: ${input.question}` })
70
+ },
71
+ } satisfies ScriptedModel)
72
+ }
73
+
74
+ /** The scripted model, when the app runs one (a real provider has no `teach`). */
75
+ export function scripted(model: Model | undefined): ScriptedModel | undefined {
76
+ return model && typeof (model as Partial<ScriptedModel>).teach === 'function'
77
+ ? (model as ScriptedModel)
78
+ : undefined
79
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "agent",
3
+ "description": "An offline agent: deterministic model, tools, memory, and a child fiber per run that holds the run's step budget.",
4
+ "requires": [],
5
+ "inject": ["model", "tools", "memory"],
6
+ "provides": ["agent", "model", "tools", "memory"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ }
10
+ }
@@ -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
+ }