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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Durable task records (`ctx.tasks`).
3
+ *
4
+ * The idea comes from Pi Durable (see spec 013, "Ideas from Pi Durable"), kept to what Cordis does not already do: every piece of work
5
+ * gets a record with an id, an optional owner (parent), its input, status, result or error, and timestamps. Each change is appended to
6
+ * `<home>/tasks.jsonl` before the call that made it returns, so nothing is presented as done before it is recorded. At startup the log is
7
+ * folded back into records, and a task still `running` is marked `interrupted`: it is reported, never silently run again, because an
8
+ * external side effect may already have happened. Cancelling a task cancels the tasks it owns and aborts their signals.
9
+ */
10
+
11
+ import { randomUUID } from 'node:crypto'
12
+ import { type Context, Service } from '@deepseek-ai/cordis'
13
+ import type {} from '../instance/index.ts'
14
+ import { openStore, type StoreKind, type TaskChange, type TaskStore } from './store.ts'
15
+
16
+ export interface TasksConfig {
17
+ /** Recorded on every task: the digest of the manifest the app was composed from. */
18
+ manifest?: string
19
+ /** Where changes are kept: a JSON lines file (default) or a SQLite file. */
20
+ store?: StoreKind
21
+ }
22
+
23
+ export type TaskStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted'
24
+
25
+ export interface TaskRecord {
26
+ readonly id: string
27
+ readonly kind: string
28
+ readonly parent?: string
29
+ /** The digest of the manifest the app was composed from, so a task traces to the configuration that ran it. */
30
+ readonly manifest?: string
31
+ readonly input: unknown
32
+ readonly status: TaskStatus
33
+ readonly result?: unknown
34
+ readonly error?: string
35
+ /** Free-form progress the owner attached (for example a tool-call trace). */
36
+ readonly detail?: unknown
37
+ readonly createdAt: string
38
+ readonly updatedAt: string
39
+ }
40
+
41
+ /** What the owner of a running task holds. */
42
+ export interface TaskHandle {
43
+ readonly id: string
44
+ /** Aborted when the task, or a task that owns it, is cancelled. */
45
+ readonly signal: AbortSignal
46
+ /** Record progress without changing the status. */
47
+ progress(detail: unknown): void
48
+ complete(result: unknown, detail?: unknown): void
49
+ fail(error: unknown, detail?: unknown): void
50
+ }
51
+
52
+ export interface TaskFilter {
53
+ readonly status?: TaskStatus
54
+ readonly parent?: string
55
+ readonly kind?: string
56
+ }
57
+
58
+ declare module '@deepseek-ai/cordis' {
59
+ interface Context {
60
+ tasks: Tasks
61
+ }
62
+
63
+ interface Events {
64
+ /** A task record changed (after the change was written). */
65
+ 'tasks/changed'(record: TaskRecord): void
66
+ }
67
+ }
68
+
69
+ type Change = Partial<TaskRecord> & { readonly id: string }
70
+ const changeOf = (change: TaskChange) => change as unknown as Change
71
+
72
+ const FINAL: ReadonlySet<TaskStatus> = new Set(['completed', 'failed', 'cancelled', 'interrupted'])
73
+ const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
74
+
75
+ export class Tasks extends Service {
76
+ readonly file: string
77
+ /** Tasks found running at startup and marked interrupted. */
78
+ readonly interrupted: readonly string[]
79
+ private readonly records = new Map<string, TaskRecord>()
80
+ private readonly controllers = new Map<string, AbortController>()
81
+
82
+ private readonly store: TaskStore
83
+ private readonly manifest: string | undefined
84
+
85
+ constructor(ctx: Context, config: TasksConfig = {}) {
86
+ super(ctx, 'tasks')
87
+ this.manifest = config.manifest
88
+ const opened = openStore(config.store ?? 'jsonl', ctx.appInstance.home)
89
+ this.file = opened.file
90
+ this.store = opened.store
91
+ // Disposers run newest first, so this one runs last: the store closes after pending work has been recorded.
92
+ ctx.effect(() => () => this.store.close(), 'tasks: close the store')
93
+ this.load()
94
+ const interrupted: string[] = []
95
+ for (const record of this.records.values()) {
96
+ if (record.status === 'running') {
97
+ this.write({
98
+ id: record.id,
99
+ status: 'interrupted',
100
+ error: 'the app stopped while this task was running',
101
+ })
102
+ interrupted.push(record.id)
103
+ }
104
+ }
105
+ this.interrupted = interrupted
106
+ // Work still running when the app stops is cancelled, so its owner's signal fires and the record says what happened.
107
+ ctx.effect(
108
+ () => () => {
109
+ for (const id of [...this.controllers.keys()])
110
+ this.finish(id, { status: 'cancelled', error: 'the app stopped' })
111
+ },
112
+ 'tasks: cancel running work',
113
+ )
114
+ }
115
+
116
+ /** Start a task. The record is written before this returns. */
117
+ start(kind: string, input: unknown, options: { parent?: string } = {}): TaskHandle {
118
+ if (options.parent !== undefined && !this.records.has(options.parent))
119
+ throw new Error(`tasks: no task "${options.parent}" to own this one`)
120
+ const id = randomUUID()
121
+ const now = new Date().toISOString()
122
+ const controller = new AbortController()
123
+ if (options.parent !== undefined) {
124
+ const parentSignal = this.controllers.get(options.parent)?.signal
125
+ if (parentSignal?.aborted) controller.abort()
126
+ }
127
+ this.controllers.set(id, controller)
128
+ this.write({
129
+ id,
130
+ kind,
131
+ ...(options.parent === undefined ? {} : { parent: options.parent }),
132
+ ...(this.manifest === undefined ? {} : { manifest: this.manifest }),
133
+ input,
134
+ status: 'running',
135
+ createdAt: now,
136
+ updatedAt: now,
137
+ })
138
+ return {
139
+ id,
140
+ signal: controller.signal,
141
+ progress: (detail) => {
142
+ if (!this.isRunning(id)) return
143
+ this.write({ id, detail })
144
+ },
145
+ complete: (result, detail) =>
146
+ this.finish(id, {
147
+ status: 'completed',
148
+ result,
149
+ ...(detail === undefined ? {} : { detail }),
150
+ }),
151
+ fail: (error, detail) =>
152
+ this.finish(id, {
153
+ status: 'failed',
154
+ error: message(error),
155
+ ...(detail === undefined ? {} : { detail }),
156
+ }),
157
+ }
158
+ }
159
+
160
+ get(id: string): TaskRecord | undefined {
161
+ return this.records.get(id)
162
+ }
163
+
164
+ /** Records, newest first. */
165
+ list(filter: TaskFilter = {}): TaskRecord[] {
166
+ return [...this.records.values()]
167
+ .filter(
168
+ (record) =>
169
+ (filter.status === undefined || record.status === filter.status) &&
170
+ (filter.parent === undefined || record.parent === filter.parent) &&
171
+ (filter.kind === undefined || record.kind === filter.kind),
172
+ )
173
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
174
+ }
175
+
176
+ /** Cancel a running task and every running task it owns, children first. Returns the ids cancelled. */
177
+ cancel(id: string): string[] {
178
+ const cancelled: string[] = []
179
+ const visit = (taskId: string) => {
180
+ for (const child of this.list({ parent: taskId })) visit(child.id)
181
+ if (this.isRunning(taskId)) {
182
+ this.finish(taskId, { status: 'cancelled', error: 'cancelled' })
183
+ cancelled.push(taskId)
184
+ }
185
+ }
186
+ if (!this.records.has(id)) throw new Error(`tasks: no task "${id}"`)
187
+ visit(id)
188
+ return cancelled
189
+ }
190
+
191
+ private isRunning(id: string): boolean {
192
+ return this.records.get(id)?.status === 'running'
193
+ }
194
+
195
+ private finish(id: string, change: Omit<Change, 'id'>): void {
196
+ if (!this.isRunning(id)) return
197
+ this.write({ id, ...change })
198
+ const controller = this.controllers.get(id)
199
+ this.controllers.delete(id)
200
+ controller?.abort()
201
+ }
202
+
203
+ /** Append one change and apply it. The line is on disk before the record changes in memory. */
204
+ private write(change: Change): void {
205
+ const now = new Date().toISOString()
206
+ const line = { updatedAt: now, ...change }
207
+ this.store.append(line)
208
+ const record = this.apply(line)
209
+ this.ctx.emit('tasks/changed', record)
210
+ }
211
+
212
+ private apply(change: Change): TaskRecord {
213
+ const current = this.records.get(change.id)
214
+ const next = { ...(current ?? {}), ...change } as TaskRecord
215
+ if (current && FINAL.has(current.status) && change.status !== undefined) return current
216
+ this.records.set(change.id, next)
217
+ return next
218
+ }
219
+
220
+ private load(): void {
221
+ for (const change of this.store.load()) this.apply(changeOf(change))
222
+ }
223
+ }
224
+
225
+ /** The plugin: mount it after `instance`. */
226
+ export const tasks = {
227
+ name: 'tasks',
228
+ inject: ['appInstance'],
229
+ apply(ctx: Context, config?: TasksConfig) {
230
+ new Tasks(ctx, config ?? {})
231
+ },
232
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "tasks",
3
+ "description": "Durable task records: id, owner, input, status, result, trace; appended to a log in the app's home and readable after a restart.",
4
+ "requires": ["instance"],
5
+ "inject": ["appInstance"],
6
+ "provides": ["tasks"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "mount it after instance: root.plugin(tasks, {}); the agent records every run as a task when it is present"
11
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Where task changes are kept. A store is an append-only log of changes: `append` must have made the change durable before it
3
+ * returns, and `load` returns every change in the order it was appended. The records are folded from the log, so a new backend only
4
+ * has to keep that promise; `store.test.ts` runs the same conformance suite against every backend.
5
+ */
6
+
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { dirname } from 'node:path'
10
+
11
+ /** One change to a task record: its id and the fields that changed. Stores treat it as opaque JSON. */
12
+ export interface TaskChange {
13
+ readonly id: string
14
+ readonly [field: string]: unknown
15
+ }
16
+
17
+ export interface TaskStore {
18
+ /** Every change so far, oldest first. A change that was only partly written (a crash mid-append) is skipped. */
19
+ load(): TaskChange[]
20
+ /** Make one change durable. */
21
+ append(change: TaskChange): void
22
+ /** Release the file. */
23
+ close(): void
24
+ }
25
+
26
+ /** One JSON object per line in a text file. Easy to read, copy and diff. */
27
+ export function jsonlStore(file: string): TaskStore {
28
+ return {
29
+ load() {
30
+ if (!existsSync(file)) return []
31
+ const changes: TaskChange[] = []
32
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
33
+ if (line.trim() === '') continue
34
+ try {
35
+ changes.push(JSON.parse(line) as TaskChange)
36
+ } catch {
37
+ // A torn last line (the app stopped mid-write) is skipped; every complete line before it still counts.
38
+ }
39
+ }
40
+ return changes
41
+ },
42
+ append(change) {
43
+ mkdirSync(dirname(file), { recursive: true })
44
+ appendFileSync(file, `${JSON.stringify(change)}\n`)
45
+ },
46
+ close() {},
47
+ }
48
+ }
49
+
50
+ /** The pieces of `node:sqlite` used here. */
51
+ interface Database {
52
+ exec(sql: string): void
53
+ prepare(sql: string): { run(...values: unknown[]): unknown; all(): Array<{ change: string }> }
54
+ close(): void
55
+ }
56
+
57
+ /**
58
+ * A SQLite file with one table, `task_changes(seq, id, change)`, appended to and never updated. Writes are synchronous with full
59
+ * durability (`synchronous = FULL`, write-ahead log), so a change is on disk before the call that made it returns. Uses Node's
60
+ * built-in `node:sqlite` (loaded only when this store is used, because Node marks it experimental and warns on load).
61
+ */
62
+ export function sqliteStore(file: string): TaskStore {
63
+ mkdirSync(dirname(file), { recursive: true })
64
+ const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as {
65
+ DatabaseSync: new (path: string) => Database
66
+ }
67
+ const db = new DatabaseSync(file)
68
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;')
69
+ db.exec(
70
+ 'CREATE TABLE IF NOT EXISTS task_changes (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, change TEXT NOT NULL)',
71
+ )
72
+ const insert = db.prepare('INSERT INTO task_changes (id, change) VALUES (?, ?)')
73
+ const select = db.prepare('SELECT change FROM task_changes ORDER BY seq')
74
+ return {
75
+ load: () => select.all().map((row) => JSON.parse(row.change) as TaskChange),
76
+ append: (change) => void insert.run(change.id, JSON.stringify(change)),
77
+ close: () => db.close(),
78
+ }
79
+ }
80
+
81
+ export type StoreKind = 'jsonl' | 'sqlite'
82
+
83
+ export function openStore(kind: StoreKind, directory: string): { store: TaskStore; file: string } {
84
+ const file = `${directory}/tasks.${kind === 'sqlite' ? 'sqlite' : 'jsonl'}`
85
+ return { store: kind === 'sqlite' ? sqliteStore(file) : jsonlStore(file), file }
86
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "noImplicitOverride": true,
11
+ "verbatimModuleSyntax": true,
12
+ "erasableSyntaxOnly": true,
13
+ "allowImportingTsExtensions": true,
14
+ "noEmit": true,
15
+ "skipLibCheck": true,
16
+ "types": ["node"]
17
+ },
18
+ "include": ["*/**/*.ts"]
19
+ }
@@ -0,0 +1,15 @@
1
+ # A container for this app: the starter pack a hosting platform runs per user.
2
+ # docker build -t my-app .
3
+ # docker run -p 3100:3100 -v my-app-data:/data -e ACRYLBLENDS_MODEL_URL=... -e ACRYLBLENDS_MODEL=... -e ACRYLBLENDS_MODEL_KEY=... my-app
4
+ # The app's own data (settings, installed extensions, the ledger, tasks) lives in /data: mount a volume there to keep what the agent built.
5
+ FROM node:24-slim
6
+ WORKDIR /app
7
+ COPY package.json ./
8
+ RUN npm install --no-audit --no-fund
9
+ COPY . .
10
+ ENV ACRYLBLENDS_HOME=/data ACRYLBLENDS_HOST=0.0.0.0 ACRYLBLENDS_PORT=3100
11
+ RUN mkdir -p /data && chown -R node:node /data /app
12
+ USER node
13
+ VOLUME /data
14
+ EXPOSE 3100
15
+ CMD ["node", "src/main.ts"]
@@ -0,0 +1,52 @@
1
+ # __APP_NAME__
2
+
3
+ A Cordis app on Node, scaffolded by `acrylblends init`. TypeScript, run by Node with no build step. Every part is a
4
+ Cordis plugin.
5
+
6
+ ```sh
7
+ npm install
8
+ npm run dev # prints its URL; data lives in .acrylblends/
9
+ npm test
10
+ npm run check # type-check
11
+
12
+ curl -X POST <url>/ask -H 'content-type: application/json' -d '{"question":"add 2 3"}'
13
+ # {"status":"completed","output":"The answer is 5.","steps":2}
14
+ ```
15
+
16
+ - `src/main.ts` chooses where the app lives (`src/plugins/instance`) and starts it.
17
+ - `blend.yaml` is the app as data: which plugins it mounts, in what order, with what config and parameters. Switch
18
+ one off with `disabled: true`. `npm run lock` writes `blend.lock.json`: the manifest's digest and a digest of
19
+ every plugin folder, so a diff of the lock shows what changed.
20
+ - `src/app.ts` holds the registry of plugins the code provides, and mounts the manifest's rows.
21
+ - `src/plugins/agent` is the offline agent: a scripted model, a tool registry, memory, and one child fiber per run.
22
+ - `src/plugins/extensions` installs, updates and removes plugins while the app runs; a failed update keeps the previous
23
+ version running. `src/plugins/builder` gives the agent tools to do that itself, writing into `extensions/`.
24
+
25
+ Give it a real model and it builds any Cordis plugin you describe (any OpenAI-compatible API; nothing is installed):
26
+
27
+ ```sh
28
+ ACRYLBLENDS_MODEL_URL=https://api.deepseek.com/v1 ACRYLBLENDS_MODEL=deepseek-chat ACRYLBLENDS_MODEL_KEY=sk-... npm run dev
29
+ ACRYLBLENDS_MODEL_URL=http://127.0.0.1:11434/v1 ACRYLBLENDS_MODEL=qwen2.5-coder npm run dev # Ollama, no key
30
+ curl -X POST <url>/ask -H 'content-type: application/json' -d '{"question":"add a GET /time route that returns the current time"}'
31
+ ```
32
+
33
+ Without a model it runs offline with a scripted model that knows these commands:
34
+
35
+ ```sh
36
+ curl -X POST <url>/ask -H 'content-type: application/json' -d '{"question":"create route hello /hello Hello"}'
37
+ curl <url>/hello # {"text":"Hello"}
38
+ curl <url>/extensions # what is installed, and its state
39
+ curl <url>/tasks # every run as a durable task, with the tool calls it owns
40
+ ```
41
+
42
+ Other commands: `update route <name> <path> <text>`, `break plugin <name>` (shows the rollback), `remove plugin <name>`,
43
+ `list plugins`. What the agent writes lands in `extensions/`, is part of your app, and starts again at the next start.
44
+ - `src/routes.ts` is your own plugin: routes added inside `ctx.effect`.
45
+ - `src/plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
46
+ Add more with `acrylblends add <plugin>`.
47
+
48
+ `npm run save -- "message"` commits the app with git (and pushes when it has a remote). It refuses a file that looks like
49
+ it holds a secret, and refuses to push a private app (anything but `visibility: public` in `blend.yaml`) to a public
50
+ remote.
51
+
52
+ Set `ACRYLBLENDS_HOME` to keep data elsewhere, and `ACRYLBLENDS_PORT` to choose the first port tried.
@@ -0,0 +1,58 @@
1
+ # The app as data, in ACRYL's Blend format (blends.acryl.dev/v1alpha1): which plugins it mounts, in what order, with what config.
2
+ # Edit this, not app.ts, to switch a plugin off (disabled: true) or change its config. `npm run lock` records what the app is built from.
3
+ # Order matters: http comes before extensions, because installed extensions start again at boot and may add routes.
4
+ apiVersion: blends.acryl.dev/v1alpha1
5
+ kind: Blueprint
6
+ metadata:
7
+ id: acrylblends.__APP_NAME__
8
+ name: __APP_NAME__
9
+ version: 0.1.0
10
+ visibility: private # `npm run save` never pushes a private app to a public remote
11
+ spec:
12
+ runtime: cordis
13
+ parameters:
14
+ # The address the server listens on.
15
+ hostname: { type: string, default: 127.0.0.1 }
16
+ # Steps an agent run may take.
17
+ steps: { type: number, default: 6 }
18
+ # Where task records are kept: jsonl (a text file) or sqlite.
19
+ taskStore: { type: string, default: jsonl }
20
+ # When true, a plugin the agent installs waits for POST /extensions/approve.
21
+ approval: { type: boolean, default: false }
22
+ rows:
23
+ - id: tasks
24
+ name: tasks
25
+ config:
26
+ store: "{{parameters.taskStore}}"
27
+ - id: model
28
+ name: model
29
+ - id: tools
30
+ name: tools
31
+ - id: memory
32
+ name: memory
33
+ - id: agent
34
+ name: agent
35
+ config:
36
+ steps: "{{parameters.steps}}"
37
+ - id: http
38
+ name: http
39
+ - id: events
40
+ name: events
41
+ config:
42
+ topics: [tasks/changed, extensions/changed]
43
+ - id: sandbox
44
+ name: sandbox
45
+ - id: extensions
46
+ name: extensions
47
+ config:
48
+ approval: "{{parameters.approval}}"
49
+ - id: builder
50
+ name: builder
51
+ - id: save
52
+ name: save
53
+ - id: routes
54
+ name: routes
55
+ - id: server
56
+ name: server
57
+ config:
58
+ hostname: "{{parameters.hostname}}"
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ .acrylblends/
3
+ .git/
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ .acrylblends/
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/main.ts",
11
+ "dev": "node --watch src/main.ts",
12
+ "test": "node --test",
13
+ "check": "tsc --noEmit",
14
+ "lock": "node src/plugins/manifest/lock.ts",
15
+ "save": "node src/plugins/save/cli.ts",
16
+ "upgrade": "node src/plugins/manifest/upgrade-cli.ts"
17
+ },
18
+ "dependencies": {},
19
+ "devDependencies": {
20
+ "@types/node": "^24.0.0",
21
+ "typescript": "6.0.3"
22
+ }
23
+ }
@@ -0,0 +1,98 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import { agent, memory, model, tools } from './plugins/agent/index.ts'
5
+ import { builder } from './plugins/builder/index.ts'
6
+ import { events } from './plugins/events/index.ts'
7
+ import { extensions } from './plugins/extensions/index.ts'
8
+ import { http } from './plugins/http/index.ts'
9
+ import { type AppInstance, instance } from './plugins/instance/index.ts'
10
+ import {
11
+ blueprintsIn,
12
+ compose,
13
+ loadManifest,
14
+ type ParameterValue,
15
+ } from './plugins/manifest/index.ts'
16
+ import { type OpenAIConfig, openai } from './plugins/openai/index.ts'
17
+ import { sandboxPlugin } from './plugins/sandbox/index.ts'
18
+ import { save } from './plugins/save/index.ts'
19
+ import { server } from './plugins/server/index.ts'
20
+ import { tasks } from './plugins/tasks/index.ts'
21
+ import { routes } from './routes.ts'
22
+
23
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
24
+ export const registry = {
25
+ tasks,
26
+ model,
27
+ tools,
28
+ memory,
29
+ agent,
30
+ http,
31
+ events,
32
+ extensions,
33
+ builder,
34
+ save,
35
+ sandbox: sandboxPlugin,
36
+ routes,
37
+ server,
38
+ }
39
+
40
+ /** The manifest next to this app's source. */
41
+ export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
42
+
43
+ /**
44
+ * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
45
+ * `createApp` and the tests read it the same way.
46
+ */
47
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
48
+ return loadManifest(MANIFEST, {
49
+ known: Object.keys(registry),
50
+ getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
51
+ ...(values ? { values } : {}),
52
+ })
53
+ }
54
+
55
+ export interface App {
56
+ readonly root: Context
57
+ readonly url: string
58
+ stop(): Promise<void>
59
+ }
60
+
61
+ /**
62
+ * The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The app's own `extensions/`
63
+ * folder is trusted to install at start, and is where the builder writes.
64
+ */
65
+ export async function createApp(
66
+ chosen: AppInstance,
67
+ options: {
68
+ values?: Readonly<Record<string, ParameterValue>>
69
+ /** A real model (any OpenAI-compatible API) in place of the offline scripted one. */
70
+ model?: OpenAIConfig
71
+ } = {},
72
+ ): Promise<App> {
73
+ const manifest = readManifest(options.values)
74
+ const extensionsDir = join(chosen.root, 'extensions')
75
+ const root = new Context()
76
+ const first = root.plugin(instance, chosen)
77
+ await first.await()
78
+ const fibers: Fiber[] = [
79
+ first,
80
+ // The `model` row mounts the scripted model, or the real one when a model is configured.
81
+ ...(await compose(root, manifest, options.model ? { ...registry, model: openai } : registry, {
82
+ ...(options.model ? { model: { ...options.model } } : {}),
83
+ // Runtime values the manifest cannot hold: the manifest's own digest (every task records it), and who may install.
84
+ tasks: { manifest: `sha256:${manifest.digest}` },
85
+ // The `approval` parameter in blend.yaml (the extensions row) turns this source into one that asks.
86
+ extensions: { sources: [{ dir: extensionsDir, trust: 'install' }] },
87
+ builder: { dir: extensionsDir },
88
+ server: { port: chosen.port.start, scan: chosen.port.scan, hostname: chosen.host },
89
+ })),
90
+ ]
91
+ return {
92
+ root,
93
+ url: root.server.url,
94
+ async stop() {
95
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
96
+ },
97
+ }
98
+ }
@@ -0,0 +1,25 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { createApp } from './app.ts'
3
+ import { selectInstance } from './plugins/instance/index.ts'
4
+ import { modelConfigFrom } from './plugins/openai/index.ts'
5
+ import { APP_NAME } from './routes.ts'
6
+
7
+ // The composition root: the one place that reads the environment. selectInstance chooses where the app lives;
8
+ // modelConfigFrom picks a real model when ACRYLBLENDS_MODEL_URL and ACRYLBLENDS_MODEL are set (ACRYLBLENDS_MODEL_KEY for hosted APIs).
9
+ const chosen = selectInstance({
10
+ projectRoot: fileURLToPath(new URL('..', import.meta.url)),
11
+ env: process.env,
12
+ })
13
+ const model = modelConfigFrom(process.env)
14
+ const app = await createApp(chosen, model ? { model } : {})
15
+ console.log(
16
+ `${APP_NAME} listening on ${app.url} (data in ${chosen.home}; model: ${model ? `${model.model} at ${model.baseUrl}` : 'offline scripted'})`,
17
+ )
18
+
19
+ const stop = async () => {
20
+ await app.stop()
21
+ console.log('stopped')
22
+ process.exit(0)
23
+ }
24
+ process.once('SIGINT', stop)
25
+ process.once('SIGTERM', stop)