acrylblends 1.0.10 → 1.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 (252) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +60 -2
  3. package/dist/index.js +1992 -33
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/api/summary.md +1 -1
  7. package/docs/framework/getting-started/desktop.md +23 -4
  8. package/docs/framework/guides/create-acrylblends.md +1 -1
  9. package/docs/framework/guides/production.md +20 -0
  10. package/docs/framework/workshop/README.md +174 -285
  11. package/docs/framework/workshop/facilitator.md +81 -90
  12. package/legacy/README.md +9 -0
  13. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  14. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  15. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  16. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  17. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  18. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  19. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  20. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  21. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  22. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  23. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  24. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  25. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  26. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  27. package/legacy/agent/855359304707/src/app.ts +168 -0
  28. package/legacy/agent/855359304707/src/routes.ts +126 -0
  29. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  30. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  31. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  32. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  33. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  34. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  35. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  36. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  37. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  38. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  39. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  40. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  41. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  42. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  43. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  44. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  45. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  46. package/legacy/layouts.json +524 -0
  47. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  48. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  49. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  50. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  51. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  52. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  53. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  54. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  55. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  56. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  57. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  58. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  59. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  60. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  61. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  62. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  63. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  64. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  65. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  66. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  67. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  68. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  69. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  70. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  71. package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
  72. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  73. package/lib/cordis/door.d.ts +44 -7
  74. package/lib/cordis/door.js +214 -60
  75. package/lib/cordis/inspect.js +1 -1
  76. package/lib/cordis/node-server.d.ts +101 -3
  77. package/lib/cordis/node-server.js +3 -3
  78. package/lib/cordis/router-http.d.ts +7 -1
  79. package/lib/cordis/router-http.js +44 -3
  80. package/lib/cordis/router.d.ts +1 -1
  81. package/lib/cordis/router.js +1 -1
  82. package/lib/cordis/state.d.ts +3 -2
  83. package/lib/cordis/state.js +371 -7
  84. package/lib/door/host.d.ts +2 -2
  85. package/lib/door/host.js +1 -1
  86. package/lib/door/page.d.ts +20 -1
  87. package/lib/door/page.js +75 -14
  88. package/lib/door/protocol.d.ts +1 -1
  89. package/lib/drivers.d.ts +10 -1
  90. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  91. package/lib/node-server.d.ts +7 -1
  92. package/lib/node-server.js +2 -2
  93. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  94. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
  95. package/lib/src-CtkIX_Gg.js +427 -0
  96. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  97. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  98. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  99. package/package.json +2 -1
  100. package/plugins/acryl-door/src/host/channel.ts +124 -9
  101. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  102. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  103. package/plugins/acryl-door/src/host/door.ts +58 -23
  104. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  105. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  106. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  107. package/plugins/acryl-door/src/index.ts +166 -29
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +11 -1
  110. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  111. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  112. package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +46 -2
  115. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  116. package/plugins/acryl-door/src/page/outline.ts +56 -0
  117. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  118. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  119. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  120. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  121. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  122. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  123. package/plugins/acryl-node-server/src/index.ts +304 -16
  124. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  125. package/plugins/acryl-page/plugin.json +12 -0
  126. package/plugins/acryl-page/src/index.ts +163 -0
  127. package/plugins/acryl-router/src/service.ts +77 -14
  128. package/plugins/acryl-router-http/src/index.ts +15 -1
  129. package/plugins/acryl-router-http/src/serve.ts +31 -0
  130. package/plugins/acryl-state/plugin.json +4 -3
  131. package/plugins/acryl-state/src/client-source.ts +244 -0
  132. package/plugins/acryl-state/src/service.ts +33 -6
  133. package/plugins/acryl-state/src/socket.ts +96 -0
  134. package/plugins/acryl-web-server/plugin.json +14 -0
  135. package/plugins/acryl-web-server/src/index.ts +164 -0
  136. package/plugins/agent/agent.ts +38 -7
  137. package/plugins/agent/index.ts +1 -1
  138. package/plugins/agent/model.ts +10 -2
  139. package/plugins/agent/tools.ts +9 -3
  140. package/plugins/agent-panel/client/describe.ts +57 -0
  141. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  142. package/plugins/agent-panel/client/frame.ts +51 -0
  143. package/plugins/agent-panel/client/main.ts +343 -81
  144. package/plugins/agent-panel/drivers.ts +7 -2
  145. package/plugins/agent-panel/headless.ts +5 -2
  146. package/plugins/agent-panel/index.ts +187 -61
  147. package/plugins/agent-panel/mount.ts +27 -0
  148. package/plugins/agent-panel/visitors.ts +231 -0
  149. package/plugins/desktop/frame.ts +231 -0
  150. package/plugins/desktop/launch.ts +44 -9
  151. package/plugins/desktop/main.ts +100 -32
  152. package/plugins/desktop/window.ts +21 -4
  153. package/plugins/driver-pty/client/terminal.ts +4 -1
  154. package/plugins/driver-pty/index.ts +38 -11
  155. package/plugins/driver-pty/spawn.ts +38 -0
  156. package/plugins/driver-pty/terminal.ts +2 -0
  157. package/plugins/extensions/index.ts +26 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/feedback/client.d.ts +22 -0
  160. package/plugins/feedback/client.js +471 -0
  161. package/plugins/feedback/index.ts +143 -0
  162. package/plugins/feedback/plugin.json +11 -0
  163. package/plugins/feedback/store.ts +307 -0
  164. package/plugins/home/index.ts +122 -0
  165. package/plugins/home/plugin.json +11 -0
  166. package/plugins/instance/index.ts +12 -3
  167. package/plugins/manifest/app-plugins.ts +86 -0
  168. package/plugins/manifest/index.ts +12 -2
  169. package/plugins/manifest/snapshot.ts +275 -0
  170. package/plugins/openai/index.ts +64 -5
  171. package/plugins/openai/prompt.ts +23 -0
  172. package/shared/AGENTS.core.md +36 -16
  173. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  174. package/shared/skills/acryl-app/SKILL.md +13 -0
  175. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  176. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  177. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  178. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  179. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  180. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  181. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  182. package/templates/agent/AGENTS.md +4 -2
  183. package/templates/agent/blend.yaml +4 -0
  184. package/templates/agent/gitignore +1 -0
  185. package/templates/agent/package.json +1 -0
  186. package/templates/agent/src/app.ts +150 -33
  187. package/templates/agent/src/frame.ts +17 -0
  188. package/templates/agent/src/main.ts +5 -3
  189. package/templates/agent/src/routes.ts +73 -21
  190. package/templates/agent/template.json +2 -0
  191. package/templates/agent/test/app.test.ts +83 -5
  192. package/templates/blank/AGENTS.md +3 -2
  193. package/templates/blank/blend.yaml +11 -0
  194. package/templates/blank/gitignore +1 -0
  195. package/templates/blank/package.json +1 -0
  196. package/templates/blank/src/app.ts +100 -29
  197. package/templates/blank/src/door.ts +7 -0
  198. package/templates/blank/src/first-page.ts +5 -0
  199. package/templates/blank/src/frame.ts +17 -0
  200. package/templates/blank/src/routes.ts +65 -8
  201. package/templates/blank/template.json +14 -1
  202. package/templates/blank/test/app.test.ts +103 -16
  203. package/templates/chat/blend.yaml +2 -1
  204. package/templates/chat/gitignore +1 -0
  205. package/templates/chat/package.json +1 -0
  206. package/templates/chat/src/app.ts +72 -22
  207. package/templates/chat/src/chat-app.ts +11 -2
  208. package/templates/chat/src/client/chat.tsx +4 -2
  209. package/templates/chat/src/frame.ts +17 -0
  210. package/templates/chat/src/main.ts +1 -6
  211. package/templates/chat/src/page.tsx +6 -4
  212. package/templates/chat/src/routes.ts +14 -4
  213. package/templates/chat/src/runtime.ts +9 -3
  214. package/templates/chat/template.json +3 -1
  215. package/templates/chat/test/chat.test.ts +46 -1
  216. package/templates/chat/test/server.test.ts +17 -3
  217. package/templates/nodejs/blend.yaml +2 -1
  218. package/templates/nodejs/gitignore +1 -0
  219. package/templates/nodejs/package.json +1 -0
  220. package/templates/nodejs/src/app.ts +76 -21
  221. package/templates/nodejs/src/frame.ts +17 -0
  222. package/templates/nodejs/src/main.ts +1 -6
  223. package/templates/nodejs/src/page.tsx +3 -3
  224. package/templates/nodejs/src/routes.ts +32 -13
  225. package/templates/nodejs/template.json +3 -1
  226. package/templates/nodejs/test/app.test.ts +38 -3
  227. package/templates/nodejs/test/server.test.ts +17 -3
  228. package/templates/notes/blend.yaml +2 -1
  229. package/templates/notes/gitignore +1 -0
  230. package/templates/notes/package.json +1 -0
  231. package/templates/notes/src/app.ts +72 -22
  232. package/templates/notes/src/frame.ts +17 -0
  233. package/templates/notes/src/main.ts +1 -6
  234. package/templates/notes/src/notes-app.ts +24 -17
  235. package/templates/notes/src/routes.ts +11 -4
  236. package/templates/notes/src/runtime.ts +9 -3
  237. package/templates/notes/src/views.tsx +26 -17
  238. package/templates/notes/template.json +3 -1
  239. package/templates/notes/test/notes.test.ts +39 -1
  240. package/templates/notes/test/server.test.ts +17 -3
  241. package/templates/todo/blend.yaml +3 -0
  242. package/templates/todo/gitignore +1 -0
  243. package/templates/todo/package.json +1 -0
  244. package/templates/todo/src/app.ts +81 -27
  245. package/templates/todo/src/door.ts +7 -0
  246. package/templates/todo/src/frame.ts +17 -0
  247. package/templates/todo/src/page.ts +13 -6
  248. package/templates/todo/src/todo.ts +18 -6
  249. package/templates/todo/template.json +11 -1
  250. package/templates/todo/test/todo.test.ts +67 -9
  251. package/lib/src-LX0E4ec4.js +0 -117
  252. package/shared/SKILL.md +0 -14
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: cordis-plugin
3
+ description: Add a Cordis plugin to __APP_NAME__ - a plugins/<name>/ folder with name, inject, apply and an optional check, its routes as an Acryl app, how its blend.yaml row mounts it, and how to verify it. Use when a feature should be its own plugin, or when asked to extend the app.
4
+ ---
5
+
6
+ # Adding a Cordis plugin
7
+
8
+ A plugin is a folder `plugins/<name>/` with an `index.ts` exporting `name` (its id), `inject` (the services it needs) and `apply(ctx)`. Everything it acquires belongs to one `ctx.effect(...)`, so unloading the plugin or stopping the app releases it.
9
+
10
+ ```ts
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import { Acryl } from 'acrylblends'
13
+
14
+ export const name = 'due-dates'
15
+ export const inject = ['router']
16
+
17
+ export function apply(ctx: Context): void {
18
+ const app = new Acryl().get('/due-dates', (c) => c.json({ ok: true }))
19
+ ctx.router.route('/due-dates', app)
20
+ }
21
+ ```
22
+
23
+ - **Routes** are an `Acryl` app mounted with `ctx.router.route('/due-dates', app)`; removing the plugin removes them. Write the app's own path: the frame that hosts the app in development adds its canvas prefix, so the same route answers `/due-dates` under `npm start` and `/app/due-dates` in the canvas. Never put the frame's prefix in a plugin's route. Import the framework by name (`acrylblends`, `acrylblends/body-limit`); never guess a relative path into another plugin.
24
+ - **State** lives in cells: `ctx.state.cell('due-dates.tasks', [])`. A page follows one with `watch` from `/api/state/client.js`, never with an `EventSource`, and never by polling what `watch` already pushes.
25
+ - **Its row starts it.** Add a row to `blend.yaml` (`- id: <name>` and `name: <name>`, before the `acryl-node-server` row); `src/app.ts` finds the folder by that name, so it is never imported there. `disabled: true` on the row switches it off (Settings > Plugins does the same). `.acryl/bin/acryl plugin add plugins/<name>` installs a folder without a row live, for trying it; a folder with a row is the app's.
26
+ - **Check it.** `npm test` and `npm run check` must pass. With the app running, use the door: `.acryl/bin/acryl look` reads the page as text with refs and `acryl act ...` acts on it; an install or reload shows the new feature in the page.
@@ -5,10 +5,12 @@ Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plug
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`.
7
7
 
8
- **Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` and an entry to the `registry` of `src/app.ts` only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
8
+ **Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` (`- id: <name>` / `name: <name>`; `src/app.ts` finds the folder by that name) only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
9
9
 
10
10
  **Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. The app has a `/settings` page already (the appearance: system, light or dark, kept in the browser) and the desktop app's menu has a Settings item that opens it; add a person's other settings to that page, not to a new one, so `acryl menu click --path "File>Settings..."` and `acryl window open --path /settings` keep opening them (on macOS the item is in the application menu). Without a desktop app these answer "no desktop window is attached; run npm run desktop".
11
11
 
12
- **Keep to two event streams a page.** A browser lets a page hold six connections to one host, across all the windows of the app, and an `EventSource` holds one for as long as the page is open. The door and the panel use none (they are WebSockets), but a page you build that streams several things in two windows can fill all six, and then every request waits. Give a page at most two streams: share one stream for several cells, or fetch what changes now and then instead.
12
+ **Pages you build follow the app's appearance.** The person chooses System, Light or Dark in `/settings`, and every open window follows it at once. A page you build gets that by putting `<script src="/appearance.js"></script>` in its `<head>` (before any other script) and colouring itself with the custom properties the script defines: `var(--bg)` for the page, `var(--fg)` for text, `var(--surface)` for cards and sidebars, `var(--border)`, `var(--muted)` for secondary text and `var(--accent)`. Do not write fixed colours (`#fff`, `white`, `#111`), `color-scheme`, or a `@media (prefers-color-scheme)` of your own: they ignore the person's choice (a page that does so stays light in a window set to Dark, or the other way round). `look` reports structure and text, not colours: check a page's colours by its CSS (every colour is a `var(--...)`), and tell the person what you did.
13
+
14
+ **Follow state with `watch`, not an `EventSource`.** A browser lets a page hold six connections to one host, across all the windows of the app, and an `EventSource` holds one for as long as the page is open: a page that streams a few things in two windows fills all six, and then every request waits. A cell of `ctx.state` (`ctx.state.cell('board.cards', [])`) is followed in the page with `import { watch } from '/api/state/client.js'` and `const stop = watch('board.cards', (cards) => { render(cards) })`: one WebSocket for the whole page carries every key (a WebSocket is not one of the six), the callback gets the value now and after every change, and `stop()` ends it. Without a WebSocket it falls back to one event stream per key and prints one warning in the console; then keep to two keys a page. Do not open an `EventSource` on `/api/state/<key>/events` yourself, and do not poll in a loop what `watch` already pushes.
13
15
 
14
16
  **Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves the agent and the builder out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. The extensions you installed (the folders of `plugins/`, committed with the app) still run in production, read-only: nothing can be installed or changed there. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
@@ -24,10 +24,13 @@ spec:
24
24
  name: tasks
25
25
  config:
26
26
  store: "{{parameters.taskStore}}"
27
+ # The model the agent asks at each step: a scripted offline one until a provider key is set.
27
28
  - id: model
28
29
  name: model
30
+ # The tools the agent can call, each with what it does and the input it takes.
29
31
  - id: tools
30
32
  name: tools
33
+ # Long-term memory for the agent.
31
34
  - id: memory
32
35
  name: memory
33
36
  - id: agent
@@ -51,6 +54,7 @@ spec:
51
54
  # The panel: a chat button on every page in development (the app's own agent, or Codex, Claude or pi started in this folder). It starts nothing in production.
52
55
  - id: agent-panel
53
56
  name: agent-panel
57
+ # The app's own pages and API.
54
58
  - id: routes
55
59
  name: routes
56
60
  - id: server
@@ -1,3 +1,4 @@
1
1
  node_modules/
2
2
  .acrylblends/
3
3
  .acryl/
4
+ package-lock.json
@@ -16,6 +16,7 @@
16
16
  "test": "node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
17
17
  "check": "tsc --noEmit",
18
18
  "lock": "node plugins/manifest/lock.ts",
19
+ "snapshot": "node plugins/manifest/snapshot.ts",
19
20
  "save": "node plugins/save/cli.ts",
20
21
  "upgrade": "node plugins/manifest/upgrade-cli.ts"
21
22
  },
@@ -6,22 +6,24 @@ import * as door from '../plugins/acryl-door/src/index.ts'
6
6
  import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
7
7
  import * as router from '../plugins/acryl-router/src/index.ts'
8
8
  import * as state from '../plugins/acryl-state/src/index.ts'
9
- import { agent, memory, model, tools } from '../plugins/agent/index.ts'
9
+ import { agent, memory, model, scripted, tools } from '../plugins/agent/index.ts'
10
10
  import * as panel from '../plugins/agent-panel/index.ts'
11
11
  import { builder } from '../plugins/builder/index.ts'
12
12
  import { extensions } from '../plugins/extensions/index.ts'
13
13
  import { type AppInstance, instance } from '../plugins/instance/index.ts'
14
14
  import {
15
+ appPluginNames,
15
16
  blueprintsIn,
16
17
  compose,
18
+ loadAppPlugins,
17
19
  loadManifest,
18
20
  type ParameterValue,
19
21
  } from '../plugins/manifest/index.ts'
20
- import { type OpenAIConfig, openai } from '../plugins/openai/index.ts'
22
+ import { ASSISTANT_PROMPT, type OpenAIConfig, openai } from '../plugins/openai/index.ts'
21
23
  import { sandboxPlugin } from '../plugins/sandbox/index.ts'
22
24
  import { save } from '../plugins/save/index.ts'
23
25
  import { tasks } from '../plugins/tasks/index.ts'
24
- import { routes } from './routes.ts'
26
+ import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
25
27
 
26
28
  /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
27
29
  export const registry = {
@@ -44,6 +46,8 @@ export const registry = {
44
46
 
45
47
  /** The manifest next to this app's source. */
46
48
  export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
49
+ /** The app's own plugins (a feature each), which `blend.yaml` mounts by their folder name next to the library copies `registry` imports. */
50
+ const PLUGINS_DIR = join(fileURLToPath(new URL('..', import.meta.url)), 'plugins')
47
51
 
48
52
  /**
49
53
  * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
@@ -51,7 +55,7 @@ export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'ble
51
55
  */
52
56
  export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
53
57
  return loadManifest(MANIFEST, {
54
- known: Object.keys(registry),
58
+ known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
55
59
  getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
56
60
  ...(values ? { values } : {}),
57
61
  })
@@ -77,28 +81,61 @@ const DEVELOPMENT_ROWS: Record<string, string> = {
77
81
  'a plugin installed live runs in this process, and POST /extensions/approve decides installs',
78
82
  }
79
83
 
84
+ /** The only tools the production Assistant's agent may call: look at the page and act on it. */
85
+ const ASSISTANT_TOOLS = ['door_look', 'door_act']
86
+
87
+ /** The most tokens one answer of the Assistant's model may use: with the step budget, what one visitor's question can cost at most. */
88
+ const ASSISTANT_MAX_TOKENS = 1024
89
+
90
+ /** Model calls per visitor question: look, open a page, look, filter, look and answer is six, with room for one wrong turn. */
91
+ const ASSISTANT_STEPS = 10
92
+
93
+ /** What the Assistant says while the app has no model: the scripted one cannot use the page. */
94
+ export const ASSISTANT_NO_MODEL =
95
+ "This assistant is not set up yet: the app's owner has not chosen a model for it."
96
+
80
97
  /**
81
- * The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The app's `plugins/`
82
- * folder is trusted to install at start, and is where the builder writes.
98
+ * The app's rows in the manifest's order, composed into `ctx`: the app's own server calls this from `createApp`, and the generated frame layer mounts it
99
+ * as one row, so the frame runs the app exactly as the app's own server does (same rows, same order, same config). `host: true` leaves the server and the
100
+ * door to the frame host, which mounts its own.
83
101
  */
84
- export async function createApp(
102
+ export async function composeInto(
103
+ ctx: Context,
85
104
  chosen: AppInstance,
86
105
  options: {
87
106
  values?: Readonly<Record<string, ParameterValue>>
88
- /** A real model (any OpenAI-compatible API) in place of the offline scripted one. */
89
107
  model?: OpenAIConfig
90
- /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
91
108
  listen?: boolean
109
+ host?: boolean
92
110
  } = {},
93
- ): Promise<App> {
111
+ ): Promise<Fiber[]> {
94
112
  const read = readManifest(options.values)
95
113
  // Rows left out: the server (and so the door, which needs it) when the app is reached in process; the door in production and with ACRYL_DOOR=off (one process at a time
96
114
  // serves a folder's door, so the tests that start several apps set it).
97
- const listen = options.listen !== false
115
+ const listen = options.listen !== false && options.host !== true
98
116
  const leaveOut = new Set<string>()
99
117
  let extensionsReadOnly = false
100
- if (!listen) leaveOut.add('acryl-node-server').add('acryl-door')
101
- if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off')
118
+ // In the frame host the infrastructure rows are the host's own (its server, its web-server port, its router, the app instance, the door): the app
119
+ // contributes only the plugins that are its own.
120
+ if (!listen || options.host === true) leaveOut.add('acryl-node-server').add('acryl-door')
121
+ if (options.host === true)
122
+ for (const row of [
123
+ 'instance',
124
+ 'acryl-router',
125
+ 'acryl-web-server',
126
+ 'acryl-page',
127
+ 'manifest',
128
+ 'save',
129
+ ])
130
+ leaveOut.add(row)
131
+ // The Assistant (production, ACRYL_ASSISTANT=on): the agent panel docked for the app's visitors. Its agent keeps only the page tools and the door only the page,
132
+ // so it uses the app for a visitor (opens pages, filters, fills in forms) and cannot change the app, its code or its secrets. Not in the frame host.
133
+ const assistant =
134
+ process.env.NODE_ENV === 'production' &&
135
+ process.env.ACRYL_ASSISTANT === 'on' &&
136
+ process.env.ACRYL_DOOR !== 'off' &&
137
+ listen
138
+ if (!assistant && (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off'))
102
139
  leaveOut.add('acryl-door')
103
140
  // Production: the rows that give a visitor power over the running app or the owner's model key are left out too. The agent (`POST /ask` spends the model key and runs tools),
104
141
  // the builder and the extensions (live plugin install runs code in this process, and `POST /extensions/approve` decides it) are development tools. `ACRYL_PRODUCTION_KEEP`
@@ -121,7 +158,7 @@ export async function createApp(
121
158
  `WARNING: the "${row}" row runs in production because ACRYL_PRODUCTION_KEEP names it: ${danger}. Nothing here is behind a login; put the app behind your own.`,
122
159
  )
123
160
  else if (row === 'extensions') extensionsReadOnly = true
124
- else leaveOut.add(row)
161
+ else if (!(assistant && row === 'agent')) leaveOut.add(row)
125
162
  }
126
163
  }
127
164
  const manifest = {
@@ -136,31 +173,111 @@ export async function createApp(
136
173
  console.warn(
137
174
  'warning: extensions/ is the old folder for plugins the agent writes; move them to plugins/ ("git mv extensions/<name> plugins/<name>"). extensions/ is read for this release only.',
138
175
  )
139
- const root = new Context()
140
- const first = root.plugin(instance, chosen)
141
- await first.await()
176
+ // The Assistant's model is told it helps a visitor through the page alone, and each answer is capped, unless the app's model configuration says otherwise.
177
+ const modelConfig =
178
+ options.model && assistant
179
+ ? {
180
+ ...options.model,
181
+ system: options.model.system ?? ASSISTANT_PROMPT,
182
+ maxTokens: options.model.maxTokens ?? ASSISTANT_MAX_TOKENS,
183
+ }
184
+ : options.model
185
+ if (assistant && !options.model)
186
+ console.warn(
187
+ `WARNING: the Assistant has no model, so it answers every visitor "${ASSISTANT_NO_MODEL}" Set the app's model (ACRYLBLENDS_MODEL_URL and ACRYLBLENDS_MODEL, or model.json in the app's home).`,
188
+ )
189
+ // The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import. Each one that runs
190
+ // is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
191
+ const ownRows = [
192
+ ...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
193
+ ]
194
+ const ownPlugins = await loadAppPlugins(
195
+ PLUGINS_DIR,
196
+ ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
197
+ )
142
198
  const fibers: Fiber[] = [
143
- first,
144
199
  // The `model` row mounts the scripted model, or the real one when a model is configured.
145
- ...(await compose(root, manifest, options.model ? { ...registry, model: openai } : registry, {
146
- ...(options.model ? { model: { ...options.model } } : {}),
147
- // Runtime values the manifest cannot hold: the manifest's own digest (every task records it), and who may install.
148
- tasks: { manifest: `sha256:${manifest.digest}` },
149
- // The `approval` parameter in blend.yaml (the extensions row) turns this source into one that asks.
150
- extensions: {
151
- ...(extensionsReadOnly ? { readOnly: true } : {}),
152
- sources: [
153
- { dir: extensionsDir, trust: 'install' },
154
- ...(legacy ? [{ dir: legacyDir, trust: 'install' as const }] : []),
155
- ],
200
+ ...(await compose(
201
+ ctx,
202
+ manifest,
203
+ { ...registry, ...ownPlugins, ...(modelConfig ? { model: openai } : {}) },
204
+ {
205
+ ...(modelConfig ? { model: { ...modelConfig } } : {}),
206
+ // Runtime values the manifest cannot hold: the manifest's own digest (every task records it), and who may install.
207
+ tasks: { manifest: `sha256:${manifest.digest}` },
208
+ // The `approval` parameter in blend.yaml (the extensions row) turns this source into one that asks.
209
+ extensions: {
210
+ ...(extensionsReadOnly ? { readOnly: true } : {}),
211
+ sources: [
212
+ { dir: extensionsDir, trust: 'install' },
213
+ ...(legacy ? [{ dir: legacyDir, trust: 'install' as const }] : []),
214
+ ],
215
+ rows: ownRows,
216
+ },
217
+ builder: { dir: extensionsDir },
218
+ ...(assistant
219
+ ? {
220
+ // Every answer comes right after a look, so it says what the visitor's page shows now.
221
+ agent: { tools: ASSISTANT_TOOLS, steps: ASSISTANT_STEPS, answerAfter: 'door_look' },
222
+ 'acryl-door': { pageOnly: true },
223
+ 'agent-panel': { assistant: true },
224
+ // The visitor reaches the agent through the panel only: no /ask, /tasks or /extensions routes.
225
+ routes: { agentRoutes: false },
226
+ }
227
+ : {}),
228
+ // The rows that serve routes of their own mount them under the app's prefix, as makeApp does. On the app's own server that prefix is the root (the
229
+ // row's default); the frame host serves the app at FRAME_MOUNT_PREFIX, and its own `/api` routes would answer for a row left at the root. The
230
+ // app's own routes row takes the same prefix for its mount and for the absolute URLs its pages write, so the page and its assets answer at `/app`.
231
+ ...(options.host === true
232
+ ? {
233
+ 'agent-panel': { mount: FRAME_MOUNT_PREFIX },
234
+ 'driver-pty': { mount: FRAME_MOUNT_PREFIX },
235
+ feedback: { mount: FRAME_MOUNT_PREFIX },
236
+ routes: { prefix: FRAME_MOUNT_PREFIX, ...(assistant ? { agentRoutes: false } : {}) },
237
+ }
238
+ : {}),
239
+ // The frame host runs the server itself and its app instance carries no port; only the app's own server configures one.
240
+ ...(options.host === true
241
+ ? {}
242
+ : {
243
+ server: {
244
+ port: chosen.port.start,
245
+ scan: chosen.port.scan ? 20 : 0,
246
+ hostname: chosen.host,
247
+ },
248
+ }),
156
249
  },
157
- builder: { dir: extensionsDir },
158
- server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0, hostname: chosen.host },
159
- })),
250
+ )),
160
251
  ]
252
+ // Without a model the scripted one would echo the visitor's words back: it says in one sentence that the Assistant is not set up.
253
+ if (assistant && !options.model)
254
+ scripted(ctx.get('model') as Parameters<typeof scripted>[0])?.teach(() => ({
255
+ text: ASSISTANT_NO_MODEL,
256
+ }))
257
+ return fibers
258
+ }
259
+
260
+ /**
261
+ * The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The app's `plugins/`
262
+ * folder is trusted to install at start, and is where the builder writes.
263
+ */
264
+ export async function createApp(
265
+ chosen: AppInstance,
266
+ options: {
267
+ values?: Readonly<Record<string, ParameterValue>>
268
+ /** A real model (any OpenAI-compatible API) in place of the offline scripted one. */
269
+ model?: OpenAIConfig
270
+ /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
271
+ listen?: boolean
272
+ } = {},
273
+ ): Promise<App> {
274
+ const root = new Context()
275
+ const first = root.plugin(instance, chosen)
276
+ await first.await()
277
+ const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
161
278
  return {
162
279
  root,
163
- url: listen ? root.nodeServer.url : '',
280
+ url: options.listen === false ? '' : root.nodeServer.url,
164
281
  async stop() {
165
282
  for (const fiber of [...fibers].reverse()) await fiber.dispose()
166
283
  },
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The app as the ACRYL frame hosts it (`npm run dev`): the generated layer mounts this row, which claims {@link FRAME_MOUNT_PREFIX} on the frame's web
3
+ * server. The app's own rows (the generated `app-plugins` row, which calls `composeInto`) mount the app at that prefix, so its pages and its routes
4
+ * answer at `/app`. Nothing here runs in production: `npm start` composes the app on its own server (no `routerHttp` service, so this row is not
5
+ * mounted there), where the routes are at the root as they always were.
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import { FRAME_MOUNT_PREFIX } from './routes.ts'
9
+
10
+ export const name = 'app-routes'
11
+
12
+ /** The frame always has it: its layer carries the web adapter. */
13
+ export const inject = ['routerHttp']
14
+
15
+ export function apply(ctx: Context): void {
16
+ ctx.routerHttp.mount(FRAME_MOUNT_PREFIX)
17
+ }
@@ -1,16 +1,18 @@
1
+ import { join } from 'node:path'
1
2
  import { fileURLToPath } from 'node:url'
2
3
  import { selectInstance } from '../plugins/instance/index.ts'
3
- import { modelConfigFrom } from '../plugins/openai/index.ts'
4
+ import { MODEL_FILE, modelConfigFrom, modelConfigFromFile } from '../plugins/openai/index.ts'
4
5
  import { createApp } from './app.ts'
5
6
  import { APP_NAME } from './routes.ts'
6
7
 
7
8
  // The composition root: the one place that reads the environment. selectInstance chooses where the app lives;
8
- // modelConfigFrom picks a real model when ACRYLBLENDS_MODEL_URL and ACRYLBLENDS_MODEL are set (ACRYLBLENDS_MODEL_KEY for hosted APIs).
9
+ // modelConfigFrom picks a real model when ACRYLBLENDS_MODEL_URL and ACRYLBLENDS_MODEL are set (ACRYLBLENDS_MODEL_KEY for hosted APIs); without them, the model kept in the app's
10
+ // home (model.json, readable by its owner only) is used, so a started app needs no flag.
9
11
  const chosen = selectInstance({
10
12
  projectRoot: fileURLToPath(new URL('..', import.meta.url)),
11
13
  env: process.env,
12
14
  })
13
- const model = modelConfigFrom(process.env)
15
+ const model = modelConfigFrom(process.env) ?? modelConfigFromFile(join(chosen.home, MODEL_FILE))
14
16
  const app = await createApp(chosen, model ? { model } : {})
15
17
  console.log(
16
18
  `${APP_NAME} listening on ${app.url} (data in ${chosen.home}; model: ${model ? `${model.model} at ${model.baseUrl}` : 'offline scripted'})`,
@@ -7,6 +7,9 @@ import type {} from '../plugins/tasks/index.ts'
7
7
 
8
8
  export const APP_NAME = '__APP_NAME__'
9
9
 
10
+ /** The only place the app's mount prefix is named: the root on its own server (`npm start`), `/app` when the ACRYL frame hosts it (`npm run dev`). */
11
+ export const FRAME_MOUNT_PREFIX = '/app'
12
+
10
13
  /** The door's page script (`.acryl/bin/acryl look` and `act`), in development only: `src/app.ts` leaves the door out in production and with ACRYL_DOOR=off. */
11
14
  const DOOR_TAG =
12
15
  process.env.NODE_ENV !== 'production' && process.env.ACRYL_DOOR !== 'off'
@@ -16,22 +19,80 @@ const DOOR_TAG =
16
19
  /**
17
20
  * The app's own routes on the router. Always: the page and `GET /health`. While the agent, the extensions and the task recorder run: `POST /ask` for the agent; `GET /extensions`
18
21
  * (and `POST /extensions/approve` and `/deny`) and `GET /tasks` to observe and steer what it did (every run is a durable task owning one task per tool call); `POST /tasks/cancel`.
19
- * What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events`. In production `src/app.ts` leaves the agent and builder rows out
22
+ * What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events` (a page follows them with `watch` of `/api/state/client.js`, over one WebSocket). In production `src/app.ts` leaves the agent and builder rows out
20
23
  * and starts the extensions read-only (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
21
24
  */
22
- /** The page's look: the person's choice from /settings (kept in this browser), applied before the page paints. */
25
+ /**
26
+ * The app's look: the person's choice from /settings (kept in this browser), as `GET /appearance.js`. One script tag in a page's head (`<script src="/appearance.js"></script>`) applies it
27
+ * before the page paints, again whenever another window of the app changes it (the `storage` event), and defines the colours as custom properties for the page to use:
28
+ * `--bg`, `--fg`, `--muted`, `--surface`, `--border`, `--accent` (`html[data-theme=dark|light]` is set when the person chose one, and the system's choice decides otherwise). A page the agent builds
29
+ * carries the tag and colours itself with `var(--bg)` and the rest, and so follows Dark and Light.
30
+ */
31
+ const APPEARANCE_JS = `(function () {
32
+ var root = document.documentElement
33
+ function apply() {
34
+ var saved
35
+ try { saved = localStorage.getItem('theme') } catch (e) { /* storage blocked: follow the system */ }
36
+ if (saved === 'dark' || saved === 'light') root.dataset.theme = saved
37
+ else delete root.dataset.theme
38
+ }
39
+ apply()
40
+ addEventListener('storage', function (event) { if (event.key === null || event.key === 'theme') apply() })
41
+ var light = '--bg:#ffffff;--fg:#111827;--muted:#6b7280;--surface:#f3f4f6;--border:#d1d5db;--accent:#2563eb'
42
+ var dark = '--bg:#111827;--fg:#f9fafb;--muted:#9ca3af;--surface:#1f2937;--border:#374151;--accent:#60a5fa'
43
+ var style = document.createElement('style')
44
+ style.id = 'appearance'
45
+ style.textContent = ':root{color-scheme:light dark;' + light + '}'
46
+ + '@media (prefers-color-scheme:dark){html:not([data-theme]){' + dark + '}}'
47
+ + 'html[data-theme=dark]{color-scheme:dark;' + dark + '}'
48
+ + 'html[data-theme=light]{color-scheme:light;' + light + '}'
49
+ + 'html{background:var(--bg);color:var(--fg)}'
50
+ ;(document.head || root).appendChild(style)
51
+ })()
52
+ `
53
+ /** The home and settings pages' own layout; their colours come from /appearance.js. */
23
54
  const APPEARANCE_STYLE =
24
- '<style>:root{color-scheme:light dark}html[data-theme=dark]{color-scheme:dark;background:#111827;color:#f9fafb}html[data-theme=light]{color-scheme:light;background:#fff;color:#111827}body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
25
- const APPEARANCE_SCRIPT =
26
- "<script>(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t}catch(e){}})()</script>"
55
+ '<style>body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
56
+ const appearanceScript = (prefix: string) => `<script src="${prefix}/appearance.js"></script>`
27
57
 
28
58
  /** `/settings`: the one setting the template has, the appearance. The desktop app's Settings menu item opens this page. */
29
- const settingsPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
59
+ const settingsPage = (prefix: string) =>
60
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${appearanceScript(prefix)}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="${prefix}/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
30
61
 
62
+ /**
63
+ * The app's own pages and routes, mountable at any prefix: the standalone server mounts it at the root, the frame row at {@link FRAME_MOUNT_PREFIX}.
64
+ * Every absolute URL a page writes is derived from the prefix it is mounted at.
65
+ */
66
+ export function makeApp(prefix = ''): Acryl {
67
+ const limit = bodyLimit({ maxSize: 64 * 1024 })
68
+ return (
69
+ new Acryl()
70
+ .use(limit)
71
+ // A browser gets a page (the door's and the agent panel's script tags are added where they are mounted); everything else gets the JSON.
72
+ .get('/', (c) =>
73
+ (c.req.header('accept') ?? '').includes('text/html')
74
+ ? c.html(
75
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${appearanceScript(prefix)}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). In ACRYL, ask it for a change in the chat beside the app (or with <code>POST /ask</code>); published with the assistant on, it helps visitors use the app without changing it.</p>${DOOR_TAG}</body></html>`,
76
+ )
77
+ : c.json({ app: APP_NAME, ok: true }),
78
+ )
79
+ .get('/health', (c) => c.json({ ok: true }))
80
+ .get('/settings', (c) => c.html(settingsPage(prefix)))
81
+ .get('/appearance.js', (c) =>
82
+ c.body(APPEARANCE_JS, 200, {
83
+ 'content-type': 'text/javascript; charset=utf-8',
84
+ 'cache-control': 'no-cache',
85
+ }),
86
+ )
87
+ )
88
+ }
89
+
90
+ /** The app's own routes: an `App` mounted on the router at `options.prefix` (empty or absent: the root). */
31
91
  export const routes = {
32
92
  name: 'routes',
33
93
  inject: ['router'],
34
- apply(ctx: Context) {
94
+ apply(ctx: Context, options: { readonly prefix?: string; readonly agentRoutes?: boolean } = {}) {
95
+ const prefix = options.prefix ?? ''
35
96
  // A body here is a name, an id or a question; anything bigger is refused before it is read.
36
97
  const json = async (c: {
37
98
  req: { json(): Promise<unknown> }
@@ -40,20 +101,11 @@ export const routes = {
40
101
  return typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {}
41
102
  }
42
103
  const limit = bodyLimit({ maxSize: 64 * 1024 })
43
- const base = new Acryl()
44
- .use(limit)
45
- // A browser gets a page (the door's and the agent panel's script tags are added where they are mounted); everything else gets the JSON.
46
- .get('/', (c) =>
47
- (c.req.header('accept') ?? '').includes('text/html')
48
- ? c.html(
49
- `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature in the agent panel on this page (or with <code>POST /ask</code>).</p>${DOOR_TAG}</body></html>`,
50
- )
51
- : c.json({ app: APP_NAME, ok: true }),
52
- )
53
- .get('/health', (c) => c.json({ ok: true }))
54
- .get('/settings', (c) => c.html(settingsPage))
55
- ctx.router.route('/', base)
104
+ // The app's own page and routes at the one prefix; the agent routes below mount at the same prefix.
105
+ ctx.router.route(prefix === '' ? '/' : prefix, makeApp(prefix))
56
106
  // The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
107
+ // `agentRoutes: false` (the production Assistant) keeps them unmounted although the agent runs.
108
+ if (options.agentRoutes === false) return
57
109
  ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
58
110
  const app = new Acryl().use(limit).get('/extensions', (c) =>
59
111
  c.json({
@@ -87,7 +139,7 @@ export const routes = {
87
139
  return c.json({ error: 'question must be a string' }, 400)
88
140
  return c.json(await inner.agent.run(question))
89
141
  })
90
- inner.router.route('/', app)
142
+ inner.router.route(prefix === '' ? '/' : prefix, app)
91
143
  })
92
144
  },
93
145
  }
@@ -4,6 +4,8 @@
4
4
  "acryl-router",
5
5
  "acryl-state",
6
6
  "acryl-node-server",
7
+ "acryl-page",
8
+ "acryl-web-server",
7
9
  "tasks",
8
10
  "agent",
9
11
  "openai",