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,39 @@
1
+ /**
2
+ * The ledger: one JSON line per change to the installed extensions, appended, never rewritten. Extracted from ACRYL's evolution ledger
3
+ * (`blend-ledger.js`): what was installed, updated, removed or refused, when, and the content digest of the version involved.
4
+ */
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
6
+ import { dirname } from 'node:path'
7
+
8
+ export type LedgerKind =
9
+ | 'installed'
10
+ | 'updated'
11
+ | 'removed'
12
+ | 'refused'
13
+ | 'restored'
14
+ | 'requested'
15
+ | 'approved'
16
+ | 'denied'
17
+
18
+ export interface LedgerEntry {
19
+ readonly at: string
20
+ readonly kind: LedgerKind
21
+ readonly name: string
22
+ readonly version?: string
23
+ readonly error?: string
24
+ }
25
+
26
+ export function appendLedger(file: string, entry: Omit<LedgerEntry, 'at'>): LedgerEntry {
27
+ const line: LedgerEntry = { at: new Date().toISOString(), ...entry }
28
+ mkdirSync(dirname(file), { recursive: true })
29
+ appendFileSync(file, `${JSON.stringify(line)}\n`)
30
+ return line
31
+ }
32
+
33
+ export function readLedger(file: string): LedgerEntry[] {
34
+ if (!existsSync(file)) return []
35
+ return readFileSync(file, 'utf8')
36
+ .split('\n')
37
+ .filter((line) => line.trim() !== '')
38
+ .map((line) => JSON.parse(line) as LedgerEntry)
39
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "extensions",
3
+ "description": "Install, update, remove and reload plugins while the app runs; a failed update brings the previous version back; a ledger of every change.",
4
+ "requires": ["instance", "sandbox"],
5
+ "inject": ["appInstance"],
6
+ "provides": ["extensions"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "@webboxes/blends-core": "0.4.0"
10
+ },
11
+ "mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, 'extensions'), trust: 'install' }] })"
12
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A staged extension lives in the app's home, which may be anywhere (a pinned home in a temporary folder), so a bare import such as
3
+ * `@deepseek-ai/cordis` would not find the app's `node_modules`. This resolve hook leaves resolution alone, and only when it fails for a
4
+ * module inside the staging folder, resolves the same name from the app's project folder. Uses Node's public `module.registerHooks`
5
+ * (ACRYL needed the same fallback on Deno); the returned function removes the hook.
6
+ */
7
+ import { mkdirSync, realpathSync } from 'node:fs'
8
+ import { registerHooks } from 'node:module'
9
+ import { join } from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
11
+
12
+ const isBare = (specifier: string) => !/^[./]/.test(specifier) && !specifier.includes(':')
13
+
14
+ export function resolveFromApp(stageRoot: string, appRoot: string): () => void {
15
+ mkdirSync(stageRoot, { recursive: true })
16
+ const staged = pathToFileURL(`${realpathSync(stageRoot)}/`).href
17
+ const parentURL = pathToFileURL(join(realpathSync(appRoot), 'package.json')).href
18
+ const hook = registerHooks({
19
+ resolve(specifier, context, nextResolve) {
20
+ try {
21
+ return nextResolve(specifier, context)
22
+ } catch (error) {
23
+ if (isBare(specifier) && context.parentURL?.startsWith(staged))
24
+ return nextResolve(specifier, { ...context, parentURL })
25
+ throw error
26
+ }
27
+ },
28
+ })
29
+ return () => hook.deregister()
30
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Staging: every installed version of an extension is a complete copy under `<home>/extensions-staged/<name>/<stamp>-<hash>/`.
3
+ *
4
+ * Extracted from ACRYL (`acryl-extension-context/lib/stage.js`). Node caches an ES module by URL, so a re-imported file keeps its old
5
+ * code; a copy at a new path has fresh URLs for the entry and everything it imports, so it is evaluated anew. The author's source folder
6
+ * is never modified. The hash makes an unchanged source recognisable; the stamp makes "newest" well defined.
7
+ */
8
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
9
+ import { basename, join } from 'node:path'
10
+ import { digestFolder, FOLDER_DIGEST_SKIPPED } from '@webboxes/blends-core'
11
+
12
+ /** A short content digest of a folder, so an unchanged source keeps its version. */
13
+ export const hashFolder = (dir: string, length = 12): string => digestFolder(dir, length)
14
+
15
+ /** Copy `source` into a new version folder under `stageRoot/<name>/` and return its path and version. */
16
+ export function stage(
17
+ source: string,
18
+ stageRoot: string,
19
+ name: string,
20
+ ): { dir: string; version: string } {
21
+ const version = hashFolder(source)
22
+ const stamp = String(Date.now()).padStart(15, '0')
23
+ const dir = join(stageRoot, name, `${stamp}-${version}`)
24
+ mkdirSync(dir, { recursive: true })
25
+ cpSync(source, dir, {
26
+ recursive: true,
27
+ filter: (path) => !FOLDER_DIGEST_SKIPPED.has(basename(path)),
28
+ })
29
+ return { dir, version }
30
+ }
31
+
32
+ /** Delete staged versions of `name` except the ones to keep (best effort). Modules already loaded stay in memory until the app restarts. */
33
+ export function prune(stageRoot: string, name: string, keep: readonly string[]): void {
34
+ const folder = join(stageRoot, name)
35
+ if (!existsSync(folder)) return
36
+ if (keep.length === 0) {
37
+ rmSync(folder, { recursive: true, force: true })
38
+ return
39
+ }
40
+ for (const entry of readdirSync(folder)) {
41
+ const path = join(folder, entry)
42
+ if (!keep.includes(path)) rmSync(path, { recursive: true, force: true })
43
+ }
44
+ }
@@ -0,0 +1,64 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+
3
+ /** A request as routes see it: the method, the path and the parsed JSON body, if any. */
4
+ export interface RouteRequest {
5
+ readonly method: string
6
+ readonly path: string
7
+ readonly body: unknown
8
+ }
9
+
10
+ /** What a route answers. The status defaults to 200; the body is sent as JSON. */
11
+ /** Sends one server-sent event: `topic` is the event name, `data` is sent as JSON. */
12
+ export type Send = (topic: string, data: unknown) => void
13
+
14
+ /** A live stream: called when a client connects, it returns what to run when the client leaves. */
15
+ export type Stream = (send: Send) => () => void
16
+
17
+ export interface Reply {
18
+ readonly status?: number
19
+ readonly body: unknown
20
+ /** Answer with a stream of server-sent events instead of a JSON body. */
21
+ readonly stream?: Stream
22
+ }
23
+
24
+ export type Handler = (request: RouteRequest) => Reply | Promise<Reply>
25
+
26
+ /** The router service. Plugins add routes; the server asks it to answer requests. */
27
+ export interface Http {
28
+ /** Add a route. Returns the function that removes it, so it can be the disposer of a `ctx.effect`. */
29
+ route(method: string, path: string, handler: Handler): () => void
30
+ /** Answer a request from the registered routes, 404 when none matches. */
31
+ handle(request: RouteRequest): Promise<{ status: number; body: unknown; stream?: Stream }>
32
+ /** The registered routes, as `METHOD /path`. */
33
+ list(): string[]
34
+ }
35
+
36
+ declare module '@deepseek-ai/cordis' {
37
+ interface Context {
38
+ http: Http
39
+ }
40
+ }
41
+
42
+ /** Provides the `http` router. Nothing listens here: see the `server` plugin. */
43
+ export function http(ctx: Context) {
44
+ const routes = new Map<string, Handler>()
45
+ ctx.provide('http', {
46
+ route(method, path, handler) {
47
+ const key = `${method.toUpperCase()} ${path}`
48
+ if (routes.has(key)) throw new Error(`route ${key} is already registered`)
49
+ routes.set(key, handler)
50
+ return () => void routes.delete(key)
51
+ },
52
+ async handle(request) {
53
+ const handler = routes.get(`${request.method.toUpperCase()} ${request.path}`)
54
+ if (!handler) return { status: 404, body: { error: 'not found' } }
55
+ const reply = await handler(request)
56
+ return {
57
+ status: reply.status ?? 200,
58
+ body: reply.body,
59
+ ...(reply.stream ? { stream: reply.stream } : {}),
60
+ }
61
+ },
62
+ list: () => [...routes.keys()].sort(),
63
+ } satisfies Http)
64
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "http",
3
+ "description": "The router service: plugins add routes inside ctx.effect, so a route disappears with the plugin that added it.",
4
+ "requires": [],
5
+ "inject": [],
6
+ "provides": ["http"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ }
10
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * App instance: everything one running app owns on this machine, decided in one place and chosen once.
3
+ *
4
+ * Extracted from ACRYL (`runtime/acryl-harness-runtime/src/instance/`): a sealed compartment per app (Release It!'s bulkhead), built by
5
+ * factories that always produce a consistent family (home, port, run lock), chosen once in `main.ts`. `selectInstance` is the only code
6
+ * that reads the environment; every plugin reads the `appInstance` service instead.
7
+ *
8
+ * Pure apart from `selectInstance`, which reads the given environment object. Paths are built, never checked.
9
+ */
10
+ import { createHash } from 'node:crypto'
11
+ import { basename, join, resolve } from 'node:path'
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+
14
+ /** How the instance was chosen: from the project folder, or a home the caller pinned. */
15
+ export type AppInstanceKind = 'project' | 'pinned'
16
+
17
+ /** Where the app listens first, and whether it moves to the next free port when that one is taken. Port 0 means any free port. */
18
+ export interface PortPreference {
19
+ readonly start: number
20
+ readonly scan: boolean
21
+ }
22
+
23
+ export interface AppInstance {
24
+ readonly kind: AppInstanceKind
25
+ /** Unique on this machine: seeds the port. */
26
+ readonly id: string
27
+ /** Human name: the project folder's name. */
28
+ readonly name: string
29
+ /** The project folder: its code and its `node_modules`. */
30
+ readonly root: string
31
+ /** The app's own data: settings, installed extensions, the extension ledger. Never shared with another app. */
32
+ readonly home: string
33
+ readonly port: PortPreference
34
+ /** The interface to listen on. Absent means loopback only; a container sets `0.0.0.0` so its published port reaches the app. */
35
+ readonly host?: string
36
+ /** Records the one live process of this instance. */
37
+ readonly runLockFile: string
38
+ }
39
+
40
+ export const HOME_DIR_NAME = '.acrylblends'
41
+ export const RUN_LOCK_FILE = 'instance.json'
42
+ export const PORT_BASE = 3100
43
+ export const PORT_SPAN = 900
44
+ export const HOME_ENV = 'ACRYLBLENDS_HOME'
45
+ export const PORT_ENV = 'ACRYLBLENDS_PORT'
46
+ export const HOST_ENV = 'ACRYLBLENDS_HOST'
47
+
48
+ export class AppInstanceError extends Error {
49
+ override name = 'AppInstanceError'
50
+ }
51
+
52
+ /** FNV-1a over the id: stable across runs and platforms, spread over the app port range, so two apps rarely start on the same port. */
53
+ export function stablePort(id: string): number {
54
+ let hash = 0x811c9dc5
55
+ for (const character of id) {
56
+ hash ^= character.codePointAt(0) ?? 0
57
+ hash = Math.imul(hash, 0x01000193) >>> 0
58
+ }
59
+ return PORT_BASE + (hash % PORT_SPAN)
60
+ }
61
+
62
+ const digest = (text: string, length: number) =>
63
+ createHash('sha1').update(text).digest('hex').slice(0, length)
64
+ const plainName = (folder: string) =>
65
+ basename(folder)
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9]+/g, '-')
68
+ .replace(/^-+|-+$/g, '') || 'app'
69
+
70
+ function family(input: {
71
+ kind: AppInstanceKind
72
+ id: string
73
+ name: string
74
+ root: string
75
+ home: string
76
+ port: PortPreference
77
+ }): AppInstance {
78
+ return Object.freeze({
79
+ kind: input.kind,
80
+ id: input.id,
81
+ name: input.name,
82
+ root: input.root,
83
+ home: input.home,
84
+ port: Object.freeze({ ...input.port }),
85
+ runLockFile: join(input.home, RUN_LOCK_FILE),
86
+ })
87
+ }
88
+
89
+ /** The default: the project folder owns its data in `<project>/.acrylblends`. Two folders with one name get different ids. */
90
+ export function projectInstance(projectRoot: string): AppInstance {
91
+ const root = resolve(projectRoot)
92
+ const name = plainName(root)
93
+ const id = `${name}-${digest(root, 4)}`
94
+ return family({
95
+ kind: 'project',
96
+ id,
97
+ name,
98
+ root,
99
+ home: join(root, HOME_DIR_NAME),
100
+ port: { start: stablePort(id), scan: true },
101
+ })
102
+ }
103
+
104
+ /**
105
+ * A home the caller pinned (ACRYLBLENDS_HOME): honoured as given. Tests and second copies of an app use it to stay apart. `root` is the
106
+ * project folder (where its packages are installed); it defaults to the current directory.
107
+ */
108
+ export function pinnedInstance(
109
+ home: string,
110
+ options: { name?: string; root?: string } = {},
111
+ ): AppInstance {
112
+ const absolute = resolve(home)
113
+ const root = resolve(options.root ?? process.cwd())
114
+ const id = `pinned-${digest(absolute, 6)}`
115
+ return family({
116
+ kind: 'pinned',
117
+ id,
118
+ name: options.name ?? plainName(root),
119
+ root,
120
+ home: absolute,
121
+ port: { start: stablePort(id), scan: true },
122
+ })
123
+ }
124
+
125
+ /** The same family with an explicit start port. Port 0 asks the system for any free port. */
126
+ export function withPort(instance: AppInstance, start: number): AppInstance {
127
+ if (!Number.isInteger(start) || start < 0 || start > 65_535) {
128
+ throw new AppInstanceError(
129
+ `the port must be 0 (any free port) or 1 to 65535, got ${String(start)}`,
130
+ )
131
+ }
132
+ return Object.freeze({ ...instance, port: Object.freeze({ start, scan: start !== 0 }) })
133
+ }
134
+
135
+ /**
136
+ * Choose the instance. The only place that reads the environment: `ACRYLBLENDS_HOME` pins the home, `ACRYLBLENDS_PORT` sets the start port, `ACRYLBLENDS_HOST` the interface;
137
+ * otherwise the project folder decides.
138
+ */
139
+ export function selectInstance(options: {
140
+ projectRoot: string
141
+ env?: Record<string, string | undefined>
142
+ }): AppInstance {
143
+ const env = options.env ?? {}
144
+ const pinned = env[HOME_ENV]?.trim()
145
+ let instance = pinned
146
+ ? pinnedInstance(pinned, { name: plainName(options.projectRoot), root: options.projectRoot })
147
+ : projectInstance(options.projectRoot)
148
+ const port = env[PORT_ENV]?.trim()
149
+ if (port) instance = withPort(instance, Number(port))
150
+ const host = env[HOST_ENV]?.trim()
151
+ if (host) instance = Object.freeze({ ...instance, host })
152
+ return instance
153
+ }
154
+
155
+ declare module '@deepseek-ai/cordis' {
156
+ interface Context {
157
+ appInstance: AppInstance
158
+ }
159
+ }
160
+
161
+ /** Provides the chosen instance as the `appInstance` service. Mount it first, with the instance as its config. */
162
+ export const instance = {
163
+ name: 'instance',
164
+ apply(ctx: Context, chosen: AppInstance) {
165
+ ctx.provide('appInstance', chosen)
166
+ },
167
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "instance",
3
+ "description": "Where the app keeps things (home, port, run lock), chosen once from the environment or the project folder.",
4
+ "requires": [],
5
+ "inject": [],
6
+ "provides": ["appInstance"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ }
10
+ }