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.
- package/bin/acrylblends.js +4 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +434 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -4
- package/plugins/agent/agent.ts +152 -0
- package/plugins/agent/index.ts +20 -0
- package/plugins/agent/memory.ts +24 -0
- package/plugins/agent/model.ts +79 -0
- package/plugins/agent/plugin.json +10 -0
- package/plugins/agent/tools.ts +50 -0
- package/plugins/builder/index.ts +193 -0
- package/plugins/builder/plugin.json +11 -0
- package/plugins/builder/script.ts +162 -0
- package/plugins/events/index.ts +42 -0
- package/plugins/events/plugin.json +9 -0
- package/plugins/extensions/index.ts +738 -0
- package/plugins/extensions/ledger.ts +39 -0
- package/plugins/extensions/plugin.json +12 -0
- package/plugins/extensions/resolve.ts +30 -0
- package/plugins/extensions/stage.ts +44 -0
- package/plugins/http/index.ts +64 -0
- package/plugins/http/plugin.json +10 -0
- package/plugins/instance/index.ts +167 -0
- package/plugins/instance/plugin.json +10 -0
- package/plugins/manifest/index.ts +635 -0
- package/plugins/manifest/lock.ts +94 -0
- package/plugins/manifest/plugin.json +13 -0
- package/plugins/manifest/upgrade-cli.ts +70 -0
- package/plugins/manifest/upgrade.ts +97 -0
- package/plugins/openai/index.ts +159 -0
- package/plugins/openai/plugin.json +11 -0
- package/plugins/openai/prompt.ts +31 -0
- package/plugins/sandbox/index.ts +150 -0
- package/plugins/sandbox/plugin.json +8 -0
- package/plugins/sandbox/runner.mjs +18 -0
- package/plugins/save/cli.ts +19 -0
- package/plugins/save/index.ts +55 -0
- package/plugins/save/plugin.json +12 -0
- package/plugins/server/index.ts +138 -0
- package/plugins/server/plugin.json +10 -0
- package/plugins/settings/index.ts +270 -0
- package/plugins/settings/merge.ts +79 -0
- package/plugins/settings/plugin.json +13 -0
- package/plugins/tasks/index.ts +232 -0
- package/plugins/tasks/plugin.json +11 -0
- package/plugins/tasks/store.ts +86 -0
- package/plugins/tsconfig.json +19 -0
- package/templates/agent/Dockerfile +15 -0
- package/templates/agent/README.md +52 -0
- package/templates/agent/blend.yaml +58 -0
- package/templates/agent/dockerignore +3 -0
- package/templates/agent/gitignore +2 -0
- package/templates/agent/package.json +23 -0
- package/templates/agent/src/app.ts +98 -0
- package/templates/agent/src/main.ts +25 -0
- package/templates/agent/src/routes.ts +62 -0
- package/templates/agent/template.json +16 -0
- package/templates/agent/test/agent.test.ts +45 -0
- package/templates/agent/test/app.test.ts +46 -0
- package/templates/agent/test/builder.test.ts +186 -0
- package/templates/agent/test/model.test.ts +89 -0
- package/templates/agent/tsconfig.json +19 -0
- package/templates/app/README.md +26 -0
- package/templates/app/blend.yaml +23 -0
- package/templates/app/gitignore +2 -0
- package/templates/app/package.json +23 -0
- package/templates/app/src/app.ts +61 -0
- package/templates/app/src/main.ts +20 -0
- package/templates/app/src/routes.ts +14 -0
- package/templates/app/template.json +1 -0
- package/templates/app/test/app.test.ts +56 -0
- package/templates/app/tsconfig.json +19 -0
- 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,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)
|