acrylblends 0.7.6 → 0.7.8

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 (105) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +148 -24
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +1 -1
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/agents/index.md +18 -3
  7. package/docs/framework/getting-started/desktop.md +2 -2
  8. package/docs/framework/guides/client-modules.md +4 -3
  9. package/docs/framework/guides/data.md +72 -8
  10. package/docs/framework/helpers/streaming.md +12 -27
  11. package/docs/framework/upstream.json +2 -2
  12. package/lib/browser/read-sse.js +84 -0
  13. package/lib/index-BLTYsyc1.d.ts +93 -0
  14. package/lib/kv/db.d.ts +12 -0
  15. package/lib/kv/db.js +57 -0
  16. package/lib/kv/json.d.ts +11 -0
  17. package/lib/kv/json.js +224 -0
  18. package/lib/kv.d.ts +2 -0
  19. package/lib/kv.js +2 -0
  20. package/lib/node-server.d.ts +3 -3
  21. package/lib/node-server.js +13 -8
  22. package/lib/read-sse.d.ts +33 -0
  23. package/lib/read-sse.js +84 -0
  24. package/lib/src-DRFVEQQb.js +90 -0
  25. package/package.json +17 -1
  26. package/plugins/acryl-db/plugin.json +1 -1
  27. package/plugins/acryl-db/upstream.json +2 -2
  28. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  29. package/plugins/acryl-inspect/upstream.json +1 -1
  30. package/plugins/acryl-kv/LICENSE +47 -0
  31. package/plugins/acryl-kv/plugin.json +12 -0
  32. package/plugins/acryl-kv/src/check.ts +58 -0
  33. package/plugins/acryl-kv/src/copy.ts +39 -0
  34. package/plugins/acryl-kv/src/cordis.ts +13 -0
  35. package/plugins/acryl-kv/src/index.ts +14 -0
  36. package/plugins/acryl-kv/src/kv.ts +37 -0
  37. package/plugins/acryl-kv/upstream.json +18 -0
  38. package/plugins/acryl-kv-db/LICENSE +47 -0
  39. package/plugins/acryl-kv-db/plugin.json +13 -0
  40. package/plugins/acryl-kv-db/src/index.ts +37 -0
  41. package/plugins/acryl-kv-db/src/open.ts +80 -0
  42. package/plugins/acryl-kv-db/upstream.json +21 -0
  43. package/plugins/acryl-kv-json/LICENSE +47 -0
  44. package/plugins/acryl-kv-json/plugin.json +13 -0
  45. package/plugins/acryl-kv-json/src/index.ts +71 -0
  46. package/plugins/acryl-kv-json/src/open.ts +249 -0
  47. package/plugins/acryl-kv-json/upstream.json +22 -0
  48. package/plugins/acryl-node-server/src/client-modules.ts +10 -9
  49. package/plugins/acryl-node-server/src/index.ts +12 -1
  50. package/plugins/acryl-node-server/upstream.json +2 -2
  51. package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
  52. package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
  53. package/plugins/acryl-router/upstream.json +2 -2
  54. package/plugins/acryl-router-http/upstream.json +1 -1
  55. package/plugins/acryl-state/upstream.json +1 -1
  56. package/plugins/builder/index.ts +2 -2
  57. package/plugins/builder/plugin.json +1 -1
  58. package/plugins/desktop/main.ts +3 -3
  59. package/plugins/desktop/plugin.json +2 -2
  60. package/plugins/desktop/upstream.json +1 -1
  61. package/plugins/desktop-web/main.ts +2 -2
  62. package/plugins/desktop-web/plugin.json +2 -2
  63. package/plugins/desktop-web/upstream.json +1 -1
  64. package/plugins/extensions/index.ts +19 -1
  65. package/plugins/extensions/plugin.json +1 -1
  66. package/plugins/extensions/resolve.ts +1 -1
  67. package/plugins/manifest/lock.ts +7 -3
  68. package/plugins/manifest/upgrade-cli.ts +1 -1
  69. package/plugins/save/cli.ts +1 -1
  70. package/plugins/save/plugin.json +1 -1
  71. package/plugins/tsconfig.json +2 -0
  72. package/shared/AGENTS.core.md +10 -10
  73. package/shared/DEFAULTS.md +40 -0
  74. package/shared/SKILL.md +14 -0
  75. package/templates/agent/AGENTS.md +1 -1
  76. package/templates/agent/README.md +6 -6
  77. package/templates/agent/gitignore +1 -0
  78. package/templates/agent/package.json +8 -8
  79. package/templates/agent/src/app.ts +28 -15
  80. package/templates/agent/src/main.ts +2 -2
  81. package/templates/agent/src/routes.ts +5 -5
  82. package/templates/agent/test/agent.test.ts +1 -1
  83. package/templates/agent/test/app.test.ts +1 -1
  84. package/templates/agent/test/builder.test.ts +1 -1
  85. package/templates/agent/test/model.test.ts +1 -1
  86. package/templates/agent/tsconfig.json +48 -50
  87. package/templates/blank/AGENTS.md +1 -1
  88. package/templates/blank/README.md +2 -2
  89. package/templates/blank/gitignore +1 -0
  90. package/templates/blank/package.json +8 -8
  91. package/templates/blank/src/about.tsx +2 -2
  92. package/templates/blank/src/app.ts +4 -4
  93. package/templates/blank/src/main.ts +1 -1
  94. package/templates/blank/src/routes.ts +1 -1
  95. package/templates/blank/test/app.test.ts +1 -1
  96. package/templates/blank/tsconfig.json +1 -1
  97. package/templates/todo/README.md +1 -1
  98. package/templates/todo/gitignore +1 -0
  99. package/templates/todo/package.json +8 -8
  100. package/templates/todo/src/app.ts +5 -5
  101. package/templates/todo/src/main.ts +1 -1
  102. package/templates/todo/src/todo.ts +4 -4
  103. package/templates/todo/test/todo.test.ts +1 -1
  104. package/templates/todo/tsconfig.json +1 -1
  105. package/templates/todo/upstream.json +2 -2
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `readSSE(response)`: the events of a `text/event-stream` answer from `fetch`, for the browser. `EventSource` reads GET streams only; a chat
3
+ * or any stream that needs a POST body, a header or an `AbortSignal` is read with `fetch`, and this is the loop every such page otherwise writes by
4
+ * hand. It follows the parsing rules of the HTML standard (lines end in LF, CRLF or CR; the `data` lines of one event join with LF; a line that
5
+ * starts with a colon is a comment; an event ends at a blank line; an event with no `data` line is not dispatched; the last event is dispatched
6
+ * only if its blank line arrived), and it is one module with no imports, so it also runs in a service worker and on a fetch runtime.
7
+ *
8
+ * ACRYL's own addition, not part of the upstream port.
9
+ */
10
+
11
+ /** One event of the stream. */
12
+ export interface SSEEvent {
13
+ /** The `event:` name; `message` when the event has none. */
14
+ readonly event: string
15
+ /** The `data:` lines of the event, joined with a line feed. */
16
+ readonly data: string
17
+ /** The `id:` of the event, when it had one. */
18
+ readonly id?: string
19
+ /** The `retry:` of the event in milliseconds, when it had one. */
20
+ readonly retry?: number
21
+ }
22
+
23
+ export interface ReadSSEOptions {
24
+ /** Stops the loop and cancels the response body when it aborts. (Abort the `fetch` itself with the signal you pass to it.) */
25
+ readonly signal?: AbortSignal
26
+ }
27
+
28
+ /**
29
+ * Read the events of a `text/event-stream` response as they arrive. Leaving the loop early (`break`, a thrown error) cancels the body, so the
30
+ * request to the server ends.
31
+ * @throws when the response is not `ok` (the status is in the message; the body is cancelled, not parsed), has no body, or fails while it is read.
32
+ */
33
+ export async function* readSSE(response: Response, options: ReadSSEOptions = {}): AsyncGenerator<SSEEvent, void, undefined> {
34
+ if (!response.ok) {
35
+ await response.body?.cancel().catch(() => undefined)
36
+ throw new Error(`readSSE: the request failed with ${String(response.status)}${response.statusText === '' ? '' : ` ${response.statusText}`}`)
37
+ }
38
+ if (response.body === null) throw new Error('readSSE: the response has no body to read')
39
+ const { signal } = options
40
+ if (signal?.aborted === true) {
41
+ await response.body.cancel().catch(() => undefined)
42
+ return
43
+ }
44
+
45
+ const reader = response.body.getReader()
46
+ const onAbort = (): void => {
47
+ void reader.cancel().catch(() => undefined)
48
+ }
49
+ signal?.addEventListener('abort', onAbort, { once: true })
50
+ const decoder = new TextDecoder('utf-8')
51
+
52
+ let buffer = ''
53
+ let skipLineFeed = false
54
+ let name = ''
55
+ let data: string[] = []
56
+ let id: string | undefined
57
+ let retry: number | undefined
58
+
59
+ /** Apply one line; returns the event when this line completes one. */
60
+ const apply = (line: string): SSEEvent | undefined => {
61
+ if (line === '') {
62
+ const event = data.length === 0 ? undefined : { event: name === '' ? 'message' : name, data: data.join('\n'), ...(id === undefined ? {} : { id }), ...(retry === undefined ? {} : { retry }) }
63
+ name = ''
64
+ data = []
65
+ id = undefined
66
+ retry = undefined
67
+ return event
68
+ }
69
+ if (line.startsWith(':')) return undefined
70
+ const colon = line.indexOf(':')
71
+ const field = colon === -1 ? line : line.slice(0, colon)
72
+ let value = colon === -1 ? '' : line.slice(colon + 1)
73
+ if (value.startsWith(' ')) value = value.slice(1)
74
+ if (field === 'event') name = value
75
+ else if (field === 'data') data.push(value)
76
+ else if (field === 'id') {
77
+ if (!value.includes('\u0000')) id = value
78
+ } else if (field === 'retry' && /^\d+$/u.test(value)) retry = Number(value)
79
+ return undefined
80
+ }
81
+
82
+ try {
83
+ for (;;) {
84
+ const { done, value } = await reader.read()
85
+ if (done) break
86
+ let text = decoder.decode(value, { stream: true })
87
+ // A CR at the end of the last chunk already ended its line; the LF that completes the CRLF may open this one.
88
+ if (skipLineFeed && text.startsWith('\n')) text = text.slice(1)
89
+ if (text !== '') skipLineFeed = false
90
+ buffer += text
91
+ let start = 0
92
+ for (let index = 0; index < buffer.length; index += 1) {
93
+ const code = buffer.charCodeAt(index)
94
+ if (code !== 10 && code !== 13) continue
95
+ const line = buffer.slice(start, index)
96
+ if (code === 13) {
97
+ if (index + 1 < buffer.length) {
98
+ if (buffer.charCodeAt(index + 1) === 10) index += 1
99
+ } else skipLineFeed = true
100
+ }
101
+ start = index + 1
102
+ const event = apply(line)
103
+ if (event !== undefined) yield event
104
+ }
105
+ buffer = buffer.slice(start)
106
+ }
107
+ } finally {
108
+ signal?.removeEventListener('abort', onAbort)
109
+ await reader.cancel().catch(() => undefined)
110
+ }
111
+ }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router",
5
- "commit": "8e23e14336647943d4232047e3ea253e434535d3"
5
+ "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -10,7 +10,7 @@
10
10
  "LICENSE"
11
11
  ],
12
12
  "rewrittenSpecifiers": 6,
13
- "digest": "sha256:07772b7e7a2cfe416e4566bebdec7e85fb4abc30466bf104e80342b9fc4ab007",
13
+ "digest": "sha256:5f07e873992fa2f0a178e029a1fe3d8c99cb4ce3ae8c07445d75431f95125417",
14
14
  "testsNotRun": {
15
15
  "tests/loader.spec.ts": "loads the built lib/ entry through the real Loader; the library ships source and has no build"
16
16
  },
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router-http",
5
- "commit": "8e23e14336647943d4232047e3ea253e434535d3"
5
+ "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-state",
5
- "commit": "8e23e14336647943d4232047e3ea253e434535d3"
5
+ "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -14,7 +14,7 @@ import type {} from '../instance/index.ts'
14
14
  import { builderRules } from './script.ts'
15
15
 
16
16
  export interface BuilderConfig {
17
- /** Where the agent writes extensions. Defaults to `<project>/extensions`. Must be one of the extensions sources. */
17
+ /** Where the agent writes extensions. Defaults to `<project>/plugins`. Must be one of the extensions sources. */
18
18
  dir?: string
19
19
  /** Where `package_plugin` writes standalone packages. Defaults to `<project>/plugin-packages`. */
20
20
  packagesDir?: string
@@ -64,7 +64,7 @@ export const builder = {
64
64
  name: 'builder',
65
65
  inject: ['extensions', 'tools', 'appInstance'],
66
66
  apply(ctx: Context, config: BuilderConfig) {
67
- const dir = resolve(config?.dir ?? join(ctx.appInstance.root, 'extensions'))
67
+ const dir = resolve(config?.dir ?? join(ctx.appInstance.root, 'plugins'))
68
68
  if (!ctx.extensions.sources.some((source) => resolve(source.dir) === dir)) {
69
69
  throw new BuilderError(
70
70
  `the builder writes into ${dir}, which is not an extensions source; add it to the extensions plugin's sources`,
@@ -7,5 +7,5 @@
7
7
  "dependencies": {
8
8
  "@deepseek-ai/cordis": "4.0.4"
9
9
  },
10
- "mount": "mount it after agent and extensions: root.plugin(builder, { dir: join(root, 'extensions') }); dir must be an extensions source"
10
+ "mount": "mount it after agent and extensions: root.plugin(builder, { dir: join(root, 'plugins') }); dir must be an extensions source"
11
11
  }
@@ -10,14 +10,14 @@ import { mkdirSync, readFileSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { app, BrowserWindow, shell } from 'electron'
13
- import type { App } from '../../app.ts'
13
+ import type { App } from '../../src/app.ts'
14
14
  import '../acryl-node-server/src/register.ts'
15
15
  import { selectInstance } from '../instance/index.ts'
16
16
  import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
17
17
  import { resolveDesktopUserDataOverride } from './user-data.ts'
18
18
  import { windowOptions } from './window.ts'
19
19
 
20
- const projectRoot = fileURLToPath(new URL('../../..', import.meta.url))
20
+ const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
21
21
  const chosen = selectInstance({ projectRoot, env: process.env })
22
22
 
23
23
  /** Where the window opens: `acrylblends.desktopPath` in package.json (the todo template sets `/todo`), else the root. */
@@ -65,7 +65,7 @@ if (!app.requestSingleInstanceLock()) {
65
65
 
66
66
  void app.whenReady().then(async () => {
67
67
  // The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
68
- const { createApp } = await import('../../app.ts')
68
+ const { createApp } = await import('../../src/app.ts')
69
69
  running = await createApp(chosen)
70
70
  const origin = new URL(running.url).origin
71
71
  window = new BrowserWindow(windowOptions({ title: chosen.name }))
@@ -9,8 +9,8 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron src/plugins/desktop/main.ts"
12
+ "desktop": "electron plugins/desktop/main.ts"
13
13
  },
14
14
  "omit": ["**/*.test.*"],
15
- "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in src/plugins/desktop)"
15
+ "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop)"
16
16
  }
@@ -5,7 +5,7 @@
5
5
  "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
6
  "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
7
  },
8
- "commit": "8e23e14336647943d4232047e3ea253e434535d3"
8
+ "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
@@ -17,7 +17,7 @@ import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
17
17
  import { resolveDesktopUserDataOverride } from './user-data.ts'
18
18
  import { windowOptions } from './window.ts'
19
19
 
20
- const projectRoot = fileURLToPath(new URL('../../..', import.meta.url))
20
+ const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
21
21
  const manifest = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')) as {
22
22
  name?: string
23
23
  acrylblends?: { desktopPath?: string }
@@ -73,7 +73,7 @@ if (!app.requestSingleInstanceLock()) {
73
73
 
74
74
  void app.whenReady().then(async () => {
75
75
  // The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
76
- const { default: acrylApp, close } = (await import('../../app.ts')) as {
76
+ const { default: acrylApp, close } = (await import('../../src/app.ts')) as {
77
77
  default: { fetch: Parameters<typeof serve>[0]['fetch'] }
78
78
  close?: () => Promise<void>
79
79
  }
@@ -9,9 +9,9 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron src/plugins/desktop-web/main.ts"
12
+ "desktop": "electron plugins/desktop-web/main.ts"
13
13
  },
14
14
  "gitignore": [".acrylblends/"],
15
15
  "omit": ["**/*.test.*"],
16
- "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in src/plugins/desktop-web)"
16
+ "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop-web)"
17
17
  }
@@ -5,7 +5,7 @@
5
5
  "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
6
  "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
7
  },
8
- "commit": "8e23e14336647943d4232047e3ea253e434535d3"
8
+ "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
@@ -55,8 +55,24 @@ export interface ExtensionSource {
55
55
  readonly trust: Trust
56
56
  }
57
57
 
58
+ /** The library copies a plugins folder holds (`library.json`, written by `acrylblends add`): the app mounts them itself, so they are not extensions. */
59
+ function libraryCopies(dir: string): ReadonlySet<string> {
60
+ try {
61
+ const listed = (
62
+ JSON.parse(readFileSync(join(dir, 'library.json'), 'utf8')) as { plugins?: unknown }
63
+ ).plugins
64
+ return new Set(
65
+ Array.isArray(listed)
66
+ ? listed.filter((name): name is string => typeof name === 'string')
67
+ : [],
68
+ )
69
+ } catch {
70
+ return new Set()
71
+ }
72
+ }
73
+
58
74
  export interface ExtensionsConfig {
59
- /** Where extension folders may come from. Defaults to `<app home>/extensions`, trusted to install. */
75
+ /** Where extension folders may come from. An app passes its `plugins/` folder (library copies listed in its `library.json` are skipped). Defaults to `<app home>/extensions`, trusted to install, for a service used without an app folder. */
60
76
  sources?: readonly ExtensionSource[]
61
77
  /** When true, every source that would install by itself asks for approval instead (`ask`). */
62
78
  approval?: boolean
@@ -373,11 +389,13 @@ export class Extensions extends Service {
373
389
  const found: Array<{ name: string; dir: string; trust: Trust }> = []
374
390
  for (const source of this.sources) {
375
391
  if (!existsSync(source.dir)) continue
392
+ const library = libraryCopies(source.dir)
376
393
  for (const entry of readdirSync(source.dir, { withFileTypes: true })) {
377
394
  const dir = join(source.dir, entry.name)
378
395
  if (
379
396
  entry.isDirectory() &&
380
397
  NAME.test(entry.name) &&
398
+ !library.has(entry.name) &&
381
399
  ENTRIES.some((file) => existsSync(join(dir, file)))
382
400
  ) {
383
401
  if (!found.some((other) => other.name === entry.name))
@@ -8,5 +8,5 @@
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "@webboxes/blends-core": "0.4.0"
10
10
  },
11
- "mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, 'extensions'), trust: 'install' }] })"
11
+ "mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, 'plugins'), trust: 'install' }] })"
12
12
  }
@@ -4,7 +4,7 @@
4
4
  * module inside the staging folder, resolves the same name from the app's project folder. Uses Node's public `module.registerHooks`
5
5
  * (ACRYL needed the same fallback on Deno); the returned function removes the hook.
6
6
  *
7
- * The framework plugins are not packages in an app (they are source under `src/plugins/`), so `aliases` names the files a framework specifier such as
7
+ * The framework plugins are not packages in an app (they are source under `plugins/`), so `aliases` names the files a framework specifier such as
8
8
  * `acryl-router` stands for. A staged plugin then imports the same module the app itself runs, so `App` is the very class the router mounts.
9
9
  */
10
10
  import { existsSync, mkdirSync, realpathSync } from 'node:fs'
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `npm run lock`: write `blend.lock.json` (formatVersion 3, the lock ACRYL writes too) next to the manifest, recording what the app is
3
3
  * built from: the manifest's identity and digest, the Blueprint it was resolved over, its resolved parameters and rows, and a content
4
- * digest of every plugin folder in `src/plugins/` and every extension in `extensions/`. Commit it; a diff of the lock is a review of
4
+ * digest of every plugin folder in `plugins/`. Commit it; a diff of the lock is a review of
5
5
  * what changed.
6
6
  */
7
7
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
@@ -69,7 +69,11 @@ export function writeLock(appRoot: string): LockV3 {
69
69
  version: document.metadata.version,
70
70
  digest: manifestDigest(text),
71
71
  },
72
- modules: [...localModules(appRoot, 'src/plugins'), ...localModules(appRoot, 'extensions')],
72
+ modules: [
73
+ ...localModules(appRoot, 'plugins'),
74
+ ...localModules(appRoot, 'src/plugins'),
75
+ ...localModules(appRoot, 'extensions'),
76
+ ],
73
77
  ...(lineage
74
78
  ? {
75
79
  lineage: {
@@ -86,7 +90,7 @@ export function writeLock(appRoot: string): LockV3 {
86
90
  }
87
91
 
88
92
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
89
- const appRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url)))
93
+ const appRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)))
90
94
  const locked = writeLock(appRoot)
91
95
  console.log(
92
96
  `wrote ${LOCK_FILE}: ${locked.rows.length} rows, ${locked.modules.length} local plugins`,
@@ -65,6 +65,6 @@ const parse = (argv: readonly string[]) =>
65
65
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
66
66
  process.exitCode = runUpgrade(
67
67
  process.argv.slice(2),
68
- resolve(fileURLToPath(new URL('../../..', import.meta.url))),
68
+ resolve(fileURLToPath(new URL('../..', import.meta.url))),
69
69
  )
70
70
  }
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
  import { saveFolder } from './index.ts'
5
5
 
6
6
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
7
- const appRoot = fileURLToPath(new URL('../../..', import.meta.url))
7
+ const appRoot = fileURLToPath(new URL('../..', import.meta.url))
8
8
  const result = saveFolder(appRoot, process.argv.slice(2).join(' ').trim() || 'save the app')
9
9
  if (result.status === 'refused') {
10
10
  console.error(`not saved: ${result.reason}`)
@@ -8,5 +8,5 @@
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "@webboxes/app-persistence": "0.1.1"
10
10
  },
11
- "mount": "add a script \"save\": \"node src/plugins/save/cli.ts\" to package.json; mount the plugin to give the agent a save_app tool"
11
+ "mount": "add a script \"save\": \"node plugins/save/cli.ts\" to package.json; mount the plugin to give the agent a save_app tool"
12
12
  }
@@ -24,6 +24,8 @@
24
24
  "acryl-node-server/tests/adapter",
25
25
  "acryl-router/tests/loader.spec.ts",
26
26
  "acryl-db-sqlite/tests/loader.spec.ts",
27
+ "acryl-kv-json/tests/loader.spec.ts",
28
+ "acryl-kv-db/tests/loader.spec.ts",
27
29
  "acryl-state/src/react.ts",
28
30
  "acryl-state/tests/react.spec.ts",
29
31
  "acryl-router-http/tests/adapter.spec.ts",
@@ -9,17 +9,17 @@ Start at `docs/framework/README.md`; `api/summary.md` is the one-page summary. R
9
9
  `api/context.md` (`c`), `cordis/validation.md` and `guides/validation.md`, `guides/helpers.md` (`streamSSE`), `concepts/middleware.md`, `cordis/plugin-lifecycle.md` (what a plugin
10
10
  owns and when it is unloaded), `cordis/mounting.md` and `cordis/node-server.md`. Pages marked "not yet available" describe parts that are not in this app.
11
11
 
12
- The docs write `import { Acryl } from 'acrylblends'` and `from 'acrylblends/<subpath>'`. In this app the framework is the vendored plugin source in `src/plugins/acryl-router/src`
12
+ The docs write `import { Acryl } from 'acrylblends'` and `from 'acrylblends/<subpath>'`. In this app the framework is the vendored plugin source in `plugins/acryl-router/src`
13
13
  (read it, do not edit it: a change belongs in ACRYL, upstream), so from a file in `src/` import:
14
14
 
15
- - `./plugins/acryl-router/src/index.ts` for `Acryl`, `Context`, `HTTPException`, `connectionOf`, `getConnInfo` and the types;
16
- - `./plugins/acryl-router/src/middleware/index.ts` for every middleware (`cors`, `bodyLimit`, `trimTrailingSlash`, `loopbackOnly`, ...);
17
- - `./plugins/acryl-router/src/helpers/index.ts` for the helpers and validators (`streamSSE`, `schemaValidator`, `validator`, cookies, `testClient`, ...).
18
- - `./plugins/acryl-router/src/core/jsx/index.ts` for `acrylblends/jsx` (`jsx`, `Fragment`, the types), and `.../core/jsx/streaming.ts` for `acrylblends/jsx/streaming`.
15
+ - `../plugins/acryl-router/src/index.ts` for `Acryl`, `Context`, `HTTPException`, `connectionOf`, `getConnInfo` and the types;
16
+ - `../plugins/acryl-router/src/middleware/index.ts` for every middleware (`cors`, `bodyLimit`, `trimTrailingSlash`, `loopbackOnly`, ...);
17
+ - `../plugins/acryl-router/src/helpers/index.ts` for the helpers and validators (`streamSSE`, `schemaValidator`, `validator`, cookies, `testClient`, ...).
18
+ - `../plugins/acryl-router/src/core/jsx/index.ts` for `acrylblends/jsx` (`jsx`, `Fragment`, the types), and `.../core/jsx/streaming.ts` for `acrylblends/jsx/streaming`.
19
19
 
20
- **JSX: write `.tsx`.** The scripts in `package.json` run Node.js with `--import ./src/plugins/acryl-node-server/src/register.ts`, which compiles `.tsx` files with this app's own `typescript`; `tsconfig.json` sets `jsx: react-jsx` and `jsxImportSource: #acryl-jsx`, which `package.json`'s `imports` maps to the vendored runtime in `src/plugins/acryl-router`. See `src/about.tsx` (blank template) for a page and `test/app.test.ts` for how it is tested. Render a component with `.toString()` and return it with `c.html(...)`; import `jsx`, `Fragment` and the types from `./plugins/acryl-router/src/core/jsx/index.ts` when you need them. A command that starts the app without those scripts (a bare `node src/main.ts`) cannot load a `.tsx` file. Without a build step you can write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')`, children after the props.
20
+ **JSX: write `.tsx`.** The scripts in `package.json` run Node.js with `--import ./plugins/acryl-node-server/src/register.ts`, which compiles `.tsx` files with this app's own `typescript`; `tsconfig.json` sets `jsx: react-jsx` and `jsxImportSource: #acryl-jsx`, which `package.json`'s `imports` maps to the vendored runtime in `plugins/acryl-router`. See `src/about.tsx` (blank template) for a page and `test/app.test.ts` for how it is tested. Render a component with `.toString()` and return it with `c.html(...)`; import `jsx`, `Fragment` and the types from `../plugins/acryl-router/src/core/jsx/index.ts` when you need them. A command that starts the app without those scripts (a bare `node src/main.ts`) cannot load a `.tsx` file. Without a build step you can write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')`, children after the props.
21
21
 
22
- A plugin written into `extensions/` while the app runs may use the docs' own names instead: `acrylblends` and `acrylblends/<subpath>` resolve to the same files. `App` is the
22
+ A plugin written into `plugins/` while the app runs may use the docs' own names instead: `acrylblends` and `acrylblends/<subpath>` resolve to the same files. `App` is the
23
23
  deprecated name of `Acryl`.
24
24
 
25
25
  ## How a feature is built
@@ -28,8 +28,8 @@ Routes are an ordinary `Acryl` app that a plugin mounts once with `ctx.router.ro
28
28
 
29
29
  ```ts
30
30
  import type { Context } from '@deepseek-ai/cordis'
31
- import { Acryl, HTTPException } from './plugins/acryl-router/src/index.ts'
32
- import { schemaValidator, type StandardSchema } from './plugins/acryl-router/src/helpers/index.ts'
31
+ import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
32
+ import { schemaValidator, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
33
33
 
34
34
  export const name = 'notes' // a plugin's name; blend.yaml refers to it
35
35
  export const inject = ['router'] // services it needs
@@ -44,7 +44,7 @@ export function apply(ctx: Context) {
44
44
 
45
45
  - A small route goes on the `App` in `src/routes.ts`. A new feature is a new plugin file: add it to `registry` in `src/app.ts`, add a row
46
46
  (`- id: notes` / `name: notes`) to `blend.yaml` before the `acryl-node-server` row, and pass runtime values (a data file) as its config in `createApp`.
47
- - State, when `src/plugins/acryl-state` exists: `const cell = ctx.state.cell<T>('notes.list', initial)` (add `'state'` to `inject`), then
47
+ - State, when `plugins/acryl-state` exists: `const cell = ctx.state.cell<T>('notes.list', initial)` (add `'state'` to `inject`), then
48
48
  `cell.get()`, `cell.set(value)`, `cell.update(fn)` and `cell.subscribe(fn)` (returns the unsubscribe). Every cell is also served as JSON at `/api/state/<key>`
49
49
  and streamed at `/api/state/<key>/events`.
50
50
  - Test a route without a socket: `await ctx.router.request('/notes')`, or boot the app as `test/app.test.ts` does and use `fetch`.
@@ -0,0 +1,40 @@
1
+
2
+ ## Defaults you can swap
3
+
4
+ The framework is not rigid. Every choice is a plugin behind a port, and what the app starts with is only a fallback. Name the default before you change it,
5
+ and swap it at the one place that mounts it.
6
+
7
+ | Choice | Default | To swap it |
8
+ | --- | --- | --- |
9
+ | Records | none until you add one: `npx acrylblends add db` mounts the `db` port on SQLite | mount another provider of `db` in `src/app.ts`; plugins use `ctx.db` and do not change |
10
+ | Small state | none until you add one: `npx acrylblends add kv` mounts the `kv` port on one JSON file | mount `acryl-kv-db` (the same port over `db`) or a provider of your own |
11
+ | Server | Node (`acryl-node-server`) | mount the adapter of another runtime in `src/app.ts` |
12
+ | Window | the browser | `npx acrylblends add desktop` adds an Electron window on the same server |
13
+
14
+ - **An outside tool** (a database server, a bundler, a router such as Vike) joins the app as a provider: a plugin in `plugins/<name>/` that implements a port and
15
+ passes that port's conformance tests, then `src/app.ts` mounts it. Postgres is such a provider and is not part of this package; add it when the app needs it.
16
+ - **The dependency rule:** only the file that mounts a provider imports it; everything else takes the port as an argument or injects it. `npx acrylblends check-deps` enforces it.
17
+
18
+ ## Plugins
19
+
20
+ There is one plugin folder: `plugins/` at the root of the app. A plugin is a folder `plugins/<name>/` with an `index.ts`. `npx acrylblends add <name>` copies a library
21
+ plugin there; a plugin you or an agent write goes there too (the `extensions` plugin installs it into the running app, and `plugins/library.json` lists the copies it must not
22
+ install a second time). Do not create `src/plugins/` or `extensions/`.
23
+
24
+ ## The door
25
+
26
+ The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app; it reaches only the app running from this folder. Its state (a secret and an audit log) lives under `.acryl/`, which git ignores: never commit it. If `.acryl/bin/acryl` is not here, this app has no door yet: run it with its scripts and check it with `npm test`.
27
+
28
+ ```
29
+ .acryl/bin/acryl brief what this app is: its plugins, where the docs are, the verbs
30
+ .acryl/bin/acryl look a snapshot of the page, with refs (r1, r2, ...)
31
+ .acryl/bin/acryl act click --ref <r>
32
+ .acryl/bin/acryl act type --ref <r> --text <t> [--submit] [--no-clear]
33
+ .acryl/bin/acryl act select --ref <r> --option <o>
34
+ .acryl/bin/acryl act press --key <Enter|Escape|...>
35
+ .acryl/bin/acryl act scroll [--ref <r>] --direction up|down [--amount <n>]
36
+ .acryl/bin/acryl act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
37
+ .acryl/bin/acryl plugin list | add <folder> | remove <name> | reload
38
+ .acryl/bin/acryl tool list | call --tool <name> --args '<json>'
39
+ ```
40
+ Every verb takes `--json`. `plugin add plugins/<name>` checks the plugin, installs it and makes it live without a restart. `tool call` reaches only the tools on the door's allowlist (empty by default). On Windows the command is `.acryl\bin\acryl.cmd`.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: app-guide
3
+ description: How to change, run and check the __APP_NAME__ app. Use it before adding a feature, a route, a plugin or a dependency to this app, and before saying the work is done.
4
+ ---
5
+
6
+ # Working in __APP_NAME__
7
+
8
+ 1. **Read first.** `AGENTS.md` in this folder says how the app is laid out. The framework's API is in `docs/framework/`: start at `docs/framework/README.md`, and
9
+ `docs/framework/api/summary.md` is the one-page summary. Parts the docs mark "not yet available" are not in this app.
10
+ 2. **Make the change small.** Your own routes are a plugin (`src/routes.ts`) that builds an `Acryl` app and mounts it; test a route with `await app.request('/path')`.
11
+ 3. **One plugin folder.** A plugin lives in `plugins/<name>/` (`npx acrylblends add <name>` copies a library plugin there). There is no `src/plugins/` and no `extensions/`.
12
+ 4. **Defaults are swappable.** The database, the key-value store, the server and the window are plugins behind ports. The table "Defaults you can swap" in `AGENTS.md` names each default and where it is swapped. Never reach around a port to a driver: `npx acrylblends check-deps` fails the check.
13
+ 5. **Look at the running app.** The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app; it reaches only the app running from this folder. Its state (a secret and an audit log) lives under `.acryl/`, which git ignores: never commit it. If `.acryl/bin/acryl` is not here, this app has no door yet: run it with its scripts and check it with `npm test`. Start with `.acryl/bin/acryl brief`, then `look` for the page and `act` to click or type on it; the full list is in `AGENTS.md`.
14
+ 6. **Done means checked.** `npm test` passes, and the change shows in the running app after a restart. No secret goes in a file or in `git status`: keys come from environment variables.
@@ -1,6 +1,6 @@
1
1
 
2
2
  ## This app (agent)
3
3
 
4
- Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plugins into `extensions/` while the app runs and install them with
4
+ Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plugins into `plugins/` while the app runs and install them with
5
5
  rollback; `GET /extensions` and `GET /tasks` show what it did. A plugin there is a folder with an `index.ts` exporting `name`, `inject`, `apply(ctx)`
6
6
  and an optional `check(ctx)` that receives the plugin's own context (so `ctx.router` reads as in `apply`). It may import `acryl-router`.
@@ -13,14 +13,14 @@ curl -X POST <url>/ask -H 'content-type: application/json' -d '{"question":"add
13
13
  # {"status":"completed","output":"The answer is 5.","steps":2}
14
14
  ```
15
15
 
16
- - `src/main.ts` chooses where the app lives (`src/plugins/instance`) and starts it.
16
+ - `src/main.ts` chooses where the app lives (`plugins/instance`) and starts it.
17
17
  - `blend.yaml` is the app as data: which plugins it mounts, in what order, with what config and parameters. Switch
18
18
  one off with `disabled: true`. `npm run lock` writes `blend.lock.json`: the manifest's digest and a digest of
19
19
  every plugin folder, so a diff of the lock shows what changed.
20
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/`.
21
+ - `plugins/agent` is the offline agent: a scripted model, a tool registry, memory, and one child fiber per run.
22
+ - `plugins/extensions` installs, updates and removes plugins while the app runs; a failed update keeps the previous
23
+ version running. `plugins/builder` gives the agent tools to do that itself, writing into `plugins/`.
24
24
 
25
25
  Give it a real model and it builds any Cordis plugin you describe (any OpenAI-compatible API; nothing is installed):
26
26
 
@@ -40,9 +40,9 @@ curl <url>/tasks # every run as a durable task, with the tool calls
40
40
  ```
41
41
 
42
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.
43
+ `list plugins`. What the agent writes lands in `plugins/`, is part of your app, and starts again at the next start.
44
44
  - `src/routes.ts` is your own plugin: it builds an `App` (`import { App } from './plugins/acryl-router/src/index.ts'`) and mounts it with `ctx.router.route(prefix, app)`. Routes added to the app after it is mounted are not served. A page mounted at `/x` answers `/x`, not `/x/` (trailing slashes are strict). The router API is documented at https://acrylblends.github.io/docs/framework/the-acryl-framework and in `docs/framework` of the acrylblends repository.
45
- - `src/plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
45
+ - `plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
46
46
  Add more with `acrylblends add <plugin>`.
47
47
 
48
48
  `npm run save -- "message"` commits the app with git (and pushes when it has a remote). It refuses a file that looks like
@@ -1,2 +1,3 @@
1
1
  node_modules/
2
2
  .acrylblends/
3
+ .acryl/
@@ -4,20 +4,20 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "imports": {
7
- "#acryl-jsx/jsx-runtime": "./src/plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
8
- "#acryl-jsx/jsx-dev-runtime": "./src/plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
7
+ "#acryl-jsx/jsx-runtime": "./plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
8
+ "#acryl-jsx/jsx-dev-runtime": "./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
9
9
  },
10
10
  "engines": {
11
11
  "node": ">=22.19.0"
12
12
  },
13
13
  "scripts": {
14
- "start": "node --import ./src/plugins/acryl-node-server/src/register.ts src/main.ts",
15
- "dev": "node --watch --import ./src/plugins/acryl-node-server/src/register.ts src/main.ts",
16
- "test": "node --import ./src/plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
14
+ "start": "node --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
15
+ "dev": "node --watch --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
16
+ "test": "node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
17
17
  "check": "tsc --noEmit",
18
- "lock": "node src/plugins/manifest/lock.ts",
19
- "save": "node src/plugins/save/cli.ts",
20
- "upgrade": "node src/plugins/manifest/upgrade-cli.ts"
18
+ "lock": "node plugins/manifest/lock.ts",
19
+ "save": "node plugins/save/cli.ts",
20
+ "upgrade": "node plugins/manifest/upgrade-cli.ts"
21
21
  },
22
22
  "dependencies": {
23
23
  "typescript": "6.0.3"