acrylblends 0.7.7 → 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 (92) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +59 -19
  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 +6 -6
  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/db/sqlite.d.ts +1 -1
  14. package/lib/db.d.ts +1 -84
  15. package/lib/index-BLTYsyc1.d.ts +93 -0
  16. package/lib/index-D4FAsBZu.d.ts +85 -0
  17. package/lib/kv/db.d.ts +2 -2
  18. package/lib/kv/json.d.ts +1 -1
  19. package/lib/kv/json.js +87 -24
  20. package/lib/kv.d.ts +1 -92
  21. package/lib/node-server.d.ts +3 -3
  22. package/lib/node-server.js +13 -8
  23. package/lib/read-sse.d.ts +33 -0
  24. package/lib/read-sse.js +84 -0
  25. package/package.json +5 -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/plugin.json +1 -1
  31. package/plugins/acryl-kv/upstream.json +1 -1
  32. package/plugins/acryl-kv-db/upstream.json +1 -1
  33. package/plugins/acryl-kv-json/src/open.ts +86 -22
  34. package/plugins/acryl-kv-json/upstream.json +2 -2
  35. package/plugins/acryl-node-server/src/client-modules.ts +10 -9
  36. package/plugins/acryl-node-server/upstream.json +2 -2
  37. package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
  38. package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
  39. package/plugins/acryl-router/upstream.json +2 -2
  40. package/plugins/acryl-router-http/upstream.json +1 -1
  41. package/plugins/acryl-state/upstream.json +1 -1
  42. package/plugins/builder/index.ts +2 -2
  43. package/plugins/builder/plugin.json +1 -1
  44. package/plugins/desktop/main.ts +3 -3
  45. package/plugins/desktop/plugin.json +2 -2
  46. package/plugins/desktop/upstream.json +1 -1
  47. package/plugins/desktop-web/main.ts +2 -2
  48. package/plugins/desktop-web/plugin.json +2 -2
  49. package/plugins/desktop-web/upstream.json +1 -1
  50. package/plugins/extensions/index.ts +19 -1
  51. package/plugins/extensions/plugin.json +1 -1
  52. package/plugins/extensions/resolve.ts +1 -1
  53. package/plugins/manifest/lock.ts +7 -3
  54. package/plugins/manifest/upgrade-cli.ts +1 -1
  55. package/plugins/save/cli.ts +1 -1
  56. package/plugins/save/plugin.json +1 -1
  57. package/shared/AGENTS.core.md +10 -10
  58. package/shared/DEFAULTS.md +40 -0
  59. package/shared/SKILL.md +14 -0
  60. package/templates/agent/AGENTS.md +1 -1
  61. package/templates/agent/README.md +6 -6
  62. package/templates/agent/gitignore +1 -0
  63. package/templates/agent/package.json +8 -8
  64. package/templates/agent/src/app.ts +28 -15
  65. package/templates/agent/src/main.ts +2 -2
  66. package/templates/agent/src/routes.ts +5 -5
  67. package/templates/agent/test/agent.test.ts +1 -1
  68. package/templates/agent/test/app.test.ts +1 -1
  69. package/templates/agent/test/builder.test.ts +1 -1
  70. package/templates/agent/test/model.test.ts +1 -1
  71. package/templates/agent/tsconfig.json +48 -50
  72. package/templates/blank/AGENTS.md +1 -1
  73. package/templates/blank/README.md +2 -2
  74. package/templates/blank/gitignore +1 -0
  75. package/templates/blank/package.json +8 -8
  76. package/templates/blank/src/about.tsx +2 -2
  77. package/templates/blank/src/app.ts +4 -4
  78. package/templates/blank/src/main.ts +1 -1
  79. package/templates/blank/src/routes.ts +1 -1
  80. package/templates/blank/test/app.test.ts +1 -1
  81. package/templates/blank/tsconfig.json +1 -1
  82. package/templates/todo/README.md +1 -1
  83. package/templates/todo/gitignore +1 -0
  84. package/templates/todo/package.json +8 -8
  85. package/templates/todo/src/app.ts +5 -5
  86. package/templates/todo/src/main.ts +1 -1
  87. package/templates/todo/src/todo.ts +4 -4
  88. package/templates/todo/test/todo.test.ts +1 -1
  89. package/templates/todo/tsconfig.json +1 -1
  90. package/templates/todo/upstream.json +2 -2
  91. package/lib/database-D_UN6We8.d.ts +0 -44
  92. package/lib/kv-DWDm0Lja.d.ts +0 -39
@@ -3,9 +3,12 @@
3
3
  * memory and resolves the `kv` port with the function that closes it. The Cordis plugin is a thin wrapper over this, so both run one code
4
4
  * path. This entry imports nothing from Cordis.
5
5
  *
6
- * Every write queues behind the writes before it and persists the whole map: serialize to a temporary file in the same folder, flush it,
7
- * rename it over the file, and flush the folder where the platform allows. The call resolves after the rename, so a crash leaves the old file
8
- * or the new one, never half of one. One process owns the file: two writers would overwrite each other.
6
+ * Every write persists the whole map: serialize to a temporary file in the same folder, flush it, rename it over the file, and flush the
7
+ * folder where the platform allows. The call resolves after the rename, so a crash leaves the old file or the new one, never half of one.
8
+ * Writes are grouped: the calls made in one turn of the event loop, and those that queue while a file write is in flight, are applied in
9
+ * call order and persisted together by one write, so a burst of calls costs one flush and one rename, not one each (on Windows each costs
10
+ * tens of milliseconds). One process owns the
11
+ * file: two writers would overwrite each other.
9
12
  */
10
13
 
11
14
  import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises'
@@ -95,6 +98,20 @@ async function flushFolder(folder: string): Promise<void> {
95
98
  }
96
99
  }
97
100
 
101
+ /** What one call does to the map: the key's new JSON text (`undefined` deletes it), and what the call resolves with. */
102
+ interface Change<T = unknown> {
103
+ readonly key: string
104
+ readonly text: string | undefined
105
+ readonly result: T
106
+ }
107
+
108
+ /** A call waiting for the file write that will contain it. Its change reads the state as the calls before it in the round left it. */
109
+ interface Pending {
110
+ readonly change: (read: (key: string) => string | undefined) => Change
111
+ readonly resolve: (result: unknown) => void
112
+ readonly reject: (error: unknown) => void
113
+ }
114
+
98
115
  /** Open (and create, when missing) a JSON-file store. @throws when the file exists and is not a readable kv file. */
99
116
  export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
100
117
  const file = resolve(options.path)
@@ -103,7 +120,12 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
103
120
  let state = await load(file)
104
121
  let closed = false
105
122
  let sequence = 0
106
- let tail: Promise<unknown> = Promise.resolve()
123
+ /** Calls waiting for the next file write, in call order. */
124
+ let queue: Pending[] = []
125
+ /** Whether the loop that writes the queue runs; cleared by the loop itself, so a call queued after it ends starts a new one. */
126
+ let draining = false
127
+ /** The latest loop, for `close` to wait on. */
128
+ let drained: Promise<void> = Promise.resolve()
107
129
 
108
130
  const guard = (): void => {
109
131
  if (closed) throw new Error(`acryl-kv-json: ${file} is closed`)
@@ -129,18 +151,62 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
129
151
  await flushFolder(dirname(file))
130
152
  }
131
153
 
132
- /** Run a change after every earlier one; the map it works on becomes the state only once it is on disk. */
133
- const write = <T>(change: (draft: Map<string, string>) => T): Promise<T> => {
134
- guard()
135
- const task = tail.then(async () => {
154
+ /**
155
+ * Write the queue until it is empty. Each round takes every call queued so far and applies them in order to one copy of the state. A call
156
+ * whose change throws rejects with its error and changes nothing, and the others in the round still apply. The copy becomes the state
157
+ * only once it is on disk. A failed file write rejects every call in its round and leaves the state as it was.
158
+ */
159
+ const drain = async (): Promise<void> => {
160
+ try {
161
+ // Let the calls made in this turn join the first round.
162
+ await Promise.resolve()
163
+ await rounds()
164
+ } finally {
165
+ draining = false
166
+ }
167
+ }
168
+
169
+ const rounds = async (): Promise<void> => {
170
+ while (queue.length > 0) {
171
+ const round = queue
172
+ queue = []
136
173
  const draft = new Map(state)
137
- const result = change(draft)
138
- await persist(draft)
174
+ const applied: { readonly call: Pending; readonly result: unknown }[] = []
175
+ for (const call of round) {
176
+ let change: Change
177
+ try {
178
+ change = call.change(key => draft.get(key))
179
+ } catch (error) {
180
+ call.reject(error)
181
+ continue
182
+ }
183
+ if (change.text === undefined) draft.delete(change.key)
184
+ else draft.set(change.key, change.text)
185
+ applied.push({ call, result: change.result })
186
+ }
187
+ if (applied.length === 0) continue
188
+ try {
189
+ await persist(draft)
190
+ } catch (error) {
191
+ for (const { call } of applied) call.reject(error)
192
+ continue
193
+ }
139
194
  state = draft
140
- return result
195
+ for (const { call, result } of applied) call.resolve(result)
196
+ }
197
+ }
198
+
199
+ /** Queue one change behind every earlier call; it resolves once a file write that contains it is on disk. */
200
+ const write = <T>(change: (read: (key: string) => string | undefined) => Change<T>): Promise<T> => {
201
+ guard()
202
+ const done = new Promise<T>((resolveCall, rejectCall) => {
203
+ queue.push({ change, resolve: resolveCall as (result: unknown) => void, reject: rejectCall })
141
204
  })
142
- tail = task.catch(() => undefined)
143
- return task
205
+ if (!draining) {
206
+ draining = true
207
+ drained = drain()
208
+ }
209
+ return done
144
210
  }
145
211
 
146
212
  const kv: Kv = {
@@ -153,21 +219,19 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
153
219
  async set(key, value) {
154
220
  checkKey(key)
155
221
  const text = encodeValue(value)
156
- await write(draft => { draft.set(key, text) })
222
+ await write(() => ({ key, text, result: undefined }))
157
223
  },
158
224
  async update<T>(key: string, change: (current: T | undefined) => T) {
159
225
  checkKey(key)
160
- return write(draft => {
161
- const text = draft.get(key)
162
- const next = runChange(change, text === undefined ? undefined : decodeValue<T>(text))
163
- const encoded = encodeValue(next)
164
- draft.set(key, encoded)
165
- return decodeValue<T>(encoded)
226
+ return write(read => {
227
+ const text = read(key)
228
+ const encoded = encodeValue(runChange(change, text === undefined ? undefined : decodeValue<T>(text)))
229
+ return { key, text: encoded, result: decodeValue<T>(encoded) }
166
230
  })
167
231
  },
168
232
  async delete(key) {
169
233
  checkKey(key)
170
- await write(draft => { draft.delete(key) })
234
+ await write(() => ({ key, text: undefined, result: undefined }))
171
235
  },
172
236
  async list(prefix = '') {
173
237
  guard()
@@ -179,7 +243,7 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
179
243
  kv,
180
244
  close: async () => {
181
245
  closed = true
182
- await tail
246
+ await drained
183
247
  },
184
248
  }
185
249
  }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-kv-json",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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:d8e3e7f9c98399d0f21f29f91323b4ce57646e59e312d4175978259d618a88cf",
13
+ "digest": "sha256:10613b53bdcf15cce86d6dce5e606d0cd93367cc2569b7603cbf157ebc557548",
14
14
  "testsNotRun": {
15
15
  "tests/loader.spec.ts": "loads the plugin through the real Loader (@deepseek-ai/cordis-plugin-loader), which generated apps do not install; the plain opener it wraps is tested here by open.spec.ts and conformance.spec.ts",
16
16
  "tests/crash.spec.ts": "kills a child process that imports the package build (lib/), which a copy vendored into an app does not have; it runs in ACRYL",
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Client components without a bundler. `clientModules({ root: 'src/client' })` is an app that serves the TypeScript and JSX modules of a
3
3
  * folder to the browser as ES modules: a `.tsx`/`.ts`/`.jsx` file compiled with the app's own `typescript` (JSX for `acrylblends/jsx/dom`),
4
- * a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`, and
5
- * `acrylblends/client`) are rewritten to two self-contained files the same app serves, at a URL relative to each module, so a page needs
4
+ * a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`,
5
+ * `acrylblends/client` and `acrylblends/read-sse`) are rewritten to three self-contained files the same app serves, at a URL relative to each module, so a page needs
6
6
  * one `<script type="module" src="/client/counter.tsx">` and no import map, under whatever path the app is routed at.
7
7
  *
8
8
  * Production runs without `typescript` (a deploy that leaves out devDependencies): `buildClientModules` writes the same compiled modules
9
- * and the two browser files into a folder, and with `NODE_ENV=production` and `compiled` set, only that folder is served.
9
+ * and the three browser files into a folder, and with `NODE_ENV=production` and `compiled` set, only that folder is served.
10
10
  */
11
11
 
12
12
  import { copyFileSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
@@ -16,7 +16,7 @@ import { Acryl } from '../../acryl-router/src/index.ts'
16
16
  import { basePath } from '../../acryl-router/src/helpers/index.ts'
17
17
  import { MissingTypeScriptError, compileModule } from './transpile.ts'
18
18
 
19
- /** The folder the two browser files are served from, below the app's mount. A source folder of this name is not served. */
19
+ /** The folder the three browser files are served from, below the app's mount. A source folder of this name is not served. */
20
20
  const RUNTIME_DIR = '@acrylblends'
21
21
  /** The framework's browser imports and the browser file that provides each. */
22
22
  const BROWSER_IMPORTS: Readonly<Record<string, string>> = {
@@ -25,8 +25,9 @@ const BROWSER_IMPORTS: Readonly<Record<string, string>> = {
25
25
  'acrylblends/jsx/dom/jsx-dev-runtime': 'jsx-dom.js',
26
26
  'acrylblends/jsx/dom/client': 'jsx-dom.js',
27
27
  'acrylblends/client': 'client.js',
28
+ 'acrylblends/read-sse': 'read-sse.js',
28
29
  }
29
- const RUNTIME_FILES = ['jsx-dom.js', 'client.js'] as const
30
+ const RUNTIME_FILES = ['jsx-dom.js', 'client.js', 'read-sse.js'] as const
30
31
  const COMPILED = new Set(['.ts', '.tsx', '.jsx', '.mts'])
31
32
  const SERVED = new Set([...COMPILED, '.js', '.mjs'])
32
33
  const JS_TYPE = 'text/javascript; charset=utf-8'
@@ -41,7 +42,7 @@ export interface ClientModulesOptions {
41
42
  readonly compiled?: string | undefined
42
43
  /** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
43
44
  readonly cacheBytes?: number | undefined
44
- /** The folder holding `jsx-dom.js` and `client.js`; the framework's own when not given. */
45
+ /** The folder holding `jsx-dom.js`, `client.js` and `read-sse.js`; the framework's own when not given. */
45
46
  readonly runtimeDir?: string | undefined
46
47
  }
47
48
 
@@ -51,11 +52,11 @@ export interface BuildClientModulesOptions {
51
52
  readonly root: string
52
53
  /** Where the compiled modules go, at the same relative paths and file names; it must not lie inside `root`. */
53
54
  readonly outDir: string
54
- /** The folder holding `jsx-dom.js` and `client.js`; the framework's own when not given. */
55
+ /** The folder holding `jsx-dom.js`, `client.js` and `read-sse.js`; the framework's own when not given. */
55
56
  readonly runtimeDir?: string | undefined
56
57
  }
57
58
 
58
- /** The framework's two browser files, next to the built module that contains this code. */
59
+ /** The framework's three browser files, next to the built module that contains this code. */
59
60
  const defaultRuntimeDir = (): string => fileURLToPath(new URL('./browser/', import.meta.url))
60
61
 
61
62
  /** A module path below the root (`widgets/counter.tsx`) with the URL prefix that reaches the browser files from it. */
@@ -264,7 +265,7 @@ function sourceFiles(root: string, dir = root): string[] {
264
265
 
265
266
  /**
266
267
  * Compile a folder of client modules once, for a production that has no `typescript`: each module is written to `outDir` at the same
267
- * relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the two
268
+ * relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the three
268
269
  * browser files are copied to `outDir/@acrylblends/`. Files already in `outDir` that no source produces are left there.
269
270
  * @param options - the source folder and the output folder.
270
271
  * @returns the paths written, relative to `outDir`.
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-node-server",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
5
+ "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -10,7 +10,7 @@
10
10
  "LICENSE"
11
11
  ],
12
12
  "rewrittenSpecifiers": 31,
13
- "digest": "sha256:e4f98422082758912834a6e454505230a18ac543b4851f6eb697b628a1acec4e",
13
+ "digest": "sha256:e92c0621600e414cffd3e063c117a887410de53113ac4f5966231b419ed92da1",
14
14
  "testsNotRun": {
15
15
  "tests/client-modules.spec.ts": "links typescript from the plugin's own node_modules (ACRYL's workspace layout); clientModules and build-client are exercised here by the templates' tests and verify:scaffolder",
16
16
  "tests/register.spec.ts": "links typescript from the plugin's own node_modules (ACRYL's workspace layout); the hook is exercised here by the templates' own .tsx tests and verify:scaffolder"
@@ -0,0 +1,3 @@
1
+ /** `readSSE` (`acrylblends/read-sse`) as one browser module with no imports, served by `clientModules`. */
2
+
3
+ export * from '../helpers/read-sse.ts'
@@ -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": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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
  }