acrylblends 0.0.0-stage → 0.3.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 (144) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +118 -0
  3. package/dist/index.js +454 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/acryl-inspect/LICENSE +21 -0
  7. package/plugins/acryl-inspect/plugin.json +11 -0
  8. package/plugins/acryl-inspect/src/index.ts +3 -0
  9. package/plugins/acryl-inspect/src/report.ts +123 -0
  10. package/plugins/acryl-inspect/src/service.ts +66 -0
  11. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  12. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  13. package/plugins/acryl-inspect/upstream.json +18 -0
  14. package/plugins/acryl-node-server/LICENSE +21 -0
  15. package/plugins/acryl-node-server/plugin.json +12 -0
  16. package/plugins/acryl-node-server/src/index.ts +94 -0
  17. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  18. package/plugins/acryl-node-server/upstream.json +18 -0
  19. package/plugins/acryl-router/LICENSE +21 -0
  20. package/plugins/acryl-router/plugin.json +12 -0
  21. package/plugins/acryl-router/src/compose.ts +21 -0
  22. package/plugins/acryl-router/src/context.ts +59 -0
  23. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  24. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  25. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  26. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  27. package/plugins/acryl-router/src/http-error.ts +21 -0
  28. package/plugins/acryl-router/src/index.ts +6 -0
  29. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  30. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  31. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  32. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  33. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  34. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  35. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  36. package/plugins/acryl-router/src/pattern.ts +89 -0
  37. package/plugins/acryl-router/src/registrar.ts +90 -0
  38. package/plugins/acryl-router/src/service.ts +164 -0
  39. package/plugins/acryl-router/src/table.ts +156 -0
  40. package/plugins/acryl-router/src/types.ts +73 -0
  41. package/plugins/acryl-router/src/util.ts +23 -0
  42. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  43. package/plugins/acryl-router/tests/conformance.ts +170 -0
  44. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  45. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  46. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  47. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  48. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  49. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  50. package/plugins/acryl-router/upstream.json +20 -0
  51. package/plugins/acryl-router-http/LICENSE +21 -0
  52. package/plugins/acryl-router-http/plugin.json +11 -0
  53. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  54. package/plugins/acryl-router-http/src/index.ts +63 -0
  55. package/plugins/acryl-router-http/src/serve.ts +24 -0
  56. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  57. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  58. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  59. package/plugins/acryl-router-http/upstream.json +22 -0
  60. package/plugins/acryl-state/LICENSE +21 -0
  61. package/plugins/acryl-state/plugin.json +12 -0
  62. package/plugins/acryl-state/src/cell.ts +67 -0
  63. package/plugins/acryl-state/src/index.ts +3 -0
  64. package/plugins/acryl-state/src/react.ts +11 -0
  65. package/plugins/acryl-state/src/service.ts +101 -0
  66. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  67. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  68. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  69. package/plugins/acryl-state/upstream.json +20 -0
  70. package/plugins/agent/agent.ts +152 -0
  71. package/plugins/agent/index.ts +20 -0
  72. package/plugins/agent/memory.ts +24 -0
  73. package/plugins/agent/model.ts +79 -0
  74. package/plugins/agent/plugin.json +10 -0
  75. package/plugins/agent/tools.ts +50 -0
  76. package/plugins/builder/index.ts +193 -0
  77. package/plugins/builder/plugin.json +11 -0
  78. package/plugins/builder/script.ts +161 -0
  79. package/plugins/extensions/index.ts +755 -0
  80. package/plugins/extensions/ledger.ts +39 -0
  81. package/plugins/extensions/plugin.json +12 -0
  82. package/plugins/extensions/resolve.ts +30 -0
  83. package/plugins/extensions/stage.ts +44 -0
  84. package/plugins/instance/index.ts +167 -0
  85. package/plugins/instance/plugin.json +10 -0
  86. package/plugins/manifest/index.ts +635 -0
  87. package/plugins/manifest/lock.ts +94 -0
  88. package/plugins/manifest/plugin.json +13 -0
  89. package/plugins/manifest/upgrade-cli.ts +70 -0
  90. package/plugins/manifest/upgrade.ts +97 -0
  91. package/plugins/openai/index.ts +159 -0
  92. package/plugins/openai/plugin.json +11 -0
  93. package/plugins/openai/prompt.ts +35 -0
  94. package/plugins/sandbox/index.ts +150 -0
  95. package/plugins/sandbox/plugin.json +8 -0
  96. package/plugins/sandbox/runner.mjs +18 -0
  97. package/plugins/save/cli.ts +19 -0
  98. package/plugins/save/index.ts +55 -0
  99. package/plugins/save/plugin.json +12 -0
  100. package/plugins/settings/index.ts +270 -0
  101. package/plugins/settings/merge.ts +79 -0
  102. package/plugins/settings/plugin.json +13 -0
  103. package/plugins/tasks/index.ts +246 -0
  104. package/plugins/tasks/plugin.json +11 -0
  105. package/plugins/tasks/store.ts +86 -0
  106. package/plugins/tsconfig.json +27 -0
  107. package/templates/agent/Dockerfile +15 -0
  108. package/templates/agent/README.md +52 -0
  109. package/templates/agent/blend.yaml +56 -0
  110. package/templates/agent/dockerignore +3 -0
  111. package/templates/agent/gitignore +2 -0
  112. package/templates/agent/package.json +23 -0
  113. package/templates/agent/src/app.ts +98 -0
  114. package/templates/agent/src/main.ts +25 -0
  115. package/templates/agent/src/routes.ts +53 -0
  116. package/templates/agent/template.json +16 -0
  117. package/templates/agent/test/agent.test.ts +45 -0
  118. package/templates/agent/test/app.test.ts +46 -0
  119. package/templates/agent/test/builder.test.ts +194 -0
  120. package/templates/agent/test/model.test.ts +88 -0
  121. package/templates/agent/tsconfig.json +19 -0
  122. package/templates/blank/README.md +26 -0
  123. package/templates/blank/blend.yaml +23 -0
  124. package/templates/blank/gitignore +2 -0
  125. package/templates/blank/package.json +23 -0
  126. package/templates/blank/src/app.ts +61 -0
  127. package/templates/blank/src/main.ts +20 -0
  128. package/templates/blank/src/routes.ts +14 -0
  129. package/templates/blank/template.json +1 -0
  130. package/templates/blank/test/app.test.ts +56 -0
  131. package/templates/blank/tsconfig.json +19 -0
  132. package/templates/todo/README.md +27 -0
  133. package/templates/todo/blend.yaml +25 -0
  134. package/templates/todo/gitignore +2 -0
  135. package/templates/todo/package.json +23 -0
  136. package/templates/todo/src/app.ts +71 -0
  137. package/templates/todo/src/main.ts +19 -0
  138. package/templates/todo/src/page.ts +28 -0
  139. package/templates/todo/src/todo.ts +96 -0
  140. package/templates/todo/template.json +1 -0
  141. package/templates/todo/test/todo.test.ts +145 -0
  142. package/templates/todo/tsconfig.json +19 -0
  143. package/templates/todo/upstream.json +12 -0
  144. package/README.md +0 -3
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The ACRYL state service (`ctx.state`): observable cells owned by plugins. A cell created from a plugin's context is removed
3
+ * when that plugin unloads, so a surface never keeps a value alive for a plugin that is gone. In process the cells are read
4
+ * and subscribed to directly (the terminal UI); with a router present they are also readable as JSON and streamable as
5
+ * server-sent events (Web and Desktop), read-only: only the Host plugin that owns a cell writes it.
6
+ */
7
+
8
+ import { Context, Service } from '@deepseek-ai/cordis'
9
+ import type { RouteContext } from '../../acryl-router/src/index.ts'
10
+ import { streamSSE } from '../../acryl-router/src/helpers/index.ts'
11
+ import { loopbackOnly } from '../../acryl-router/src/middleware/index.ts'
12
+ import { createCell, createDerived, type Cell, type ReadonlyCell } from './cell.ts'
13
+
14
+ declare module '@deepseek-ai/cordis' {
15
+ interface Context {
16
+ state: State
17
+ }
18
+ }
19
+
20
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
21
+ export const name = 'acryl-state'
22
+
23
+ const KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/
24
+
25
+ export class State extends Service {
26
+ private readonly cells = new Map<string, ReadonlyCell<unknown>>()
27
+
28
+ constructor(ctx: Context) {
29
+ super(ctx, 'state')
30
+ }
31
+
32
+ private claim(key: string): void {
33
+ if (!KEY_PATTERN.test(key)) throw new TypeError(`acryl-state: key "${key}" must be dotted lowercase words, like "market.sources"`)
34
+ if (this.cells.has(key)) throw new Error(`acryl-state: key "${key}" is already in use`)
35
+ }
36
+
37
+ private report = (error: unknown): void => {
38
+ this.ctx.logger.error(`acryl-state: a subscriber failed: ${error instanceof Error ? error.message : String(error)}`)
39
+ }
40
+
41
+ /** Create a writable cell owned by the calling plugin. The key must be new. */
42
+ cell<T>(key: string, initial: T): Cell<T> {
43
+ this.claim(key)
44
+ const cell = createCell(key, initial, this.report)
45
+ this.cells.set(key, cell as ReadonlyCell<unknown>)
46
+ this.ctx.effect(() => () => { this.cells.delete(key) }, `acryl-state: cell ${key}`)
47
+ return cell
48
+ }
49
+
50
+ /** Create a read-only cell computed from other cells, owned by the calling plugin. */
51
+ derive<D extends readonly ReadonlyCell<unknown>[], T>(
52
+ key: string,
53
+ inputs: D,
54
+ compute: (...values: { [K in keyof D]: D[K] extends ReadonlyCell<infer V> ? V : never }) => T,
55
+ ): ReadonlyCell<T> {
56
+ this.claim(key)
57
+ const derived = createDerived(key, inputs, compute, this.report)
58
+ this.cells.set(key, derived.cell as ReadonlyCell<unknown>)
59
+ this.ctx.effect(() => () => { derived.dispose(); this.cells.delete(key) }, `acryl-state: derived ${key}`)
60
+ return derived.cell
61
+ }
62
+
63
+ /** A cell by key, if one is live. */
64
+ find<T = unknown>(key: string): ReadonlyCell<T> | undefined {
65
+ return this.cells.get(key) as ReadonlyCell<T> | undefined
66
+ }
67
+
68
+ keys(): string[] {
69
+ return [...this.cells.keys()].sort()
70
+ }
71
+
72
+ /**
73
+ * The current value of `key` as a server-sent event stream: the value now, then one event per change, until the request is aborted, the client
74
+ * cancels, or the router is disposed. `streamSSE` holds the request in flight until then, so the router waits for the stream.
75
+ */
76
+ stream(key: string, c: RouteContext): Response | undefined {
77
+ const cell = this.find(key)
78
+ if (cell === undefined) return undefined
79
+ return streamSSE(c, stream => {
80
+ stream.send(cell.get(), { event: 'value' })
81
+ return cell.subscribe(value => { stream.send(value, { event: 'value' }) })
82
+ })
83
+ }
84
+ }
85
+
86
+ export function apply(ctx: Context): void {
87
+ const state = new State(ctx)
88
+ // The same cells on the router, read-only, for any surface; present only where a router is. A request over a socket must come from this machine to this
89
+ // app; the terminal calls the router in process and is the host's own code.
90
+ ctx.inject(['router'], inner => {
91
+ const api = inner.router.group('/api/state', loopbackOnly({ inProcess: 'allow' }))
92
+ api.get('/', c => c.json({ keys: state.keys() }))
93
+ api.get('/:key', c => {
94
+ const cell = state.find(c.param('key') ?? '')
95
+ return cell === undefined ? c.json({ error: 'no such state' }, 404) : c.json({ key: cell.key, value: cell.get() })
96
+ })
97
+ api.get('/:key/events', c => state.stream(c.param('key') ?? '', c) ?? c.json({ error: 'no such state' }, 404))
98
+ // Over HTTP only where an adapter exists (Web and Desktop); the terminal needs none.
99
+ inner.inject(['routerHttp'], http => { http.routerHttp.mount('/api/state') })
100
+ })
101
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createCell, createDerived } from '../src/cell.ts'
3
+
4
+ describe('cells', () => {
5
+ it('holds a value, notifies on change with the previous value, and ignores a set to an equal value', () => {
6
+ const cell = createCell('a.b', 1)
7
+ const seen: Array<[number, number]> = []
8
+ cell.subscribe((value, previous) => seen.push([value, previous]))
9
+ cell.set(2)
10
+ cell.set(2)
11
+ cell.update(n => n + 1)
12
+ expect(cell.get()).toBe(3)
13
+ expect(seen).toEqual([[2, 1], [3, 2]])
14
+ })
15
+
16
+ it('stops notifying after unsubscribe, and unsubscribing twice is harmless', () => {
17
+ const cell = createCell('a', 0)
18
+ const listener = vi.fn()
19
+ const stop = cell.subscribe(listener)
20
+ cell.set(1)
21
+ stop()
22
+ stop()
23
+ cell.set(2)
24
+ expect(listener).toHaveBeenCalledTimes(1)
25
+ })
26
+
27
+ it('isolates a failing subscriber: the others still run and the error is reported', () => {
28
+ const report = vi.fn()
29
+ const cell = createCell('a', 0, report)
30
+ const after = vi.fn()
31
+ cell.subscribe(() => { throw new Error('boom') })
32
+ cell.subscribe(after)
33
+ cell.set(1)
34
+ expect(after).toHaveBeenCalledWith(1, 0)
35
+ expect(report).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ it('lets a subscriber unsubscribe itself while being notified', () => {
39
+ const cell = createCell('a', 0)
40
+ const calls: number[] = []
41
+ const stop = cell.subscribe(value => { calls.push(value); stop() })
42
+ cell.subscribe(value => calls.push(value * 10))
43
+ cell.set(1)
44
+ cell.set(2)
45
+ expect(calls).toEqual([1, 10, 20])
46
+ })
47
+ })
48
+
49
+ describe('derived cells', () => {
50
+ it('recomputes from its inputs, notifies only when its own value changes, and detaches on dispose', () => {
51
+ const a = createCell('a', 2)
52
+ const b = createCell('b', 3)
53
+ const { cell: sum, dispose } = createDerived('sum', [a, b] as const, (x, y) => x + y)
54
+ const seen: number[] = []
55
+ sum.subscribe(value => seen.push(value))
56
+ expect(sum.get()).toBe(5)
57
+ a.set(10)
58
+ b.set(1)
59
+ expect(seen).toEqual([13, 11])
60
+ const parity = createDerived('parity', [a] as const, x => x % 2 === 0).cell
61
+ const flips: boolean[] = []
62
+ parity.subscribe(value => flips.push(value))
63
+ a.set(12)
64
+ a.set(14)
65
+ expect(flips).toEqual([])
66
+ dispose()
67
+ a.set(100)
68
+ // Detached: it keeps the last value it computed (14 + 1), not 100 + 1.
69
+ expect(sum.get()).toBe(15)
70
+ })
71
+ })
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render } from '@testing-library/react'
3
+ import { createElement } from 'react'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { createCell } from '../src/cell.ts'
6
+ import { useCell } from '../src/react.ts'
7
+
8
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
9
+ afterEach(cleanup)
10
+
11
+ function Counter({ cell }: { cell: ReturnType<typeof createCell<number>> }) {
12
+ return createElement('span', { 'data-testid': 'v' }, String(useCell(cell)))
13
+ }
14
+
15
+ describe('useCell', () => {
16
+ it('renders the current value and re-renders when the cell changes', () => {
17
+ const cell = createCell('r.count', 1)
18
+ const view = render(createElement(Counter, { cell }))
19
+ expect(view.getByTestId('v').textContent).toBe('1')
20
+ act(() => { cell.set(2) })
21
+ expect(view.getByTestId('v').textContent).toBe('2')
22
+ })
23
+
24
+ it('unsubscribes on unmount, and a second component on the same cell stays in step', () => {
25
+ const cell = createCell('r.count', 0)
26
+ let live = 0
27
+ const watched = { ...cell, subscribe: (listener: Parameters<typeof cell.subscribe>[0]) => { live += 1; const stop = cell.subscribe(listener); return () => { live -= 1; stop() } } }
28
+ const a = render(createElement(Counter, { cell: watched }))
29
+ const b = render(createElement(Counter, { cell: watched }))
30
+ expect(live).toBe(2)
31
+ act(() => { cell.set(7) })
32
+ expect(a.container.querySelector('[data-testid=v]')?.textContent).toBe('7')
33
+ expect(b.container.querySelector('[data-testid=v]')?.textContent).toBe('7')
34
+ a.unmount()
35
+ expect(live).toBe(1)
36
+ b.unmount()
37
+ expect(live).toBe(0)
38
+ })
39
+ })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The state service through a real Cordis `Context`: cells are owned by the plugin that made them, readable and
3
+ * subscribable in process, and streamed through the router as server-sent events, with cancellation and disposal.
4
+ */
5
+
6
+ import { Context } from '@deepseek-ai/cordis'
7
+ import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+ import { apply, name } from '../src/index.ts'
10
+
11
+ const contexts: Context[] = []
12
+ const settle = (ms = 0): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
13
+ afterEach(async () => {
14
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
15
+ })
16
+
17
+ async function system(withRouter = true): Promise<Context> {
18
+ const ctx = new Context()
19
+ contexts.push(ctx)
20
+ if (withRouter) ctx.plugin({ name: routerName, apply: routerApply }, { trailingSlash: 'strict' })
21
+ ctx.plugin({ name, apply })
22
+ await settle()
23
+ return ctx
24
+ }
25
+
26
+ /** A tiny SSE reader over a Response body. */
27
+ async function events(response: Response, count: number): Promise<string[]> {
28
+ const reader = response.body!.getReader()
29
+ const decoder = new TextDecoder()
30
+ let buffer = ''
31
+ const out: string[] = []
32
+ while (out.length < count) {
33
+ const { done, value } = await reader.read()
34
+ if (done) break
35
+ buffer += decoder.decode(value, { stream: true })
36
+ for (let at = buffer.indexOf('\n\n'); at !== -1; at = buffer.indexOf('\n\n')) {
37
+ out.push(buffer.slice(0, at))
38
+ buffer = buffer.slice(at + 2)
39
+ }
40
+ }
41
+ reader.releaseLock()
42
+ return out
43
+ }
44
+
45
+ describe('acryl-state', () => {
46
+ it('creates cells owned by the calling plugin and removes them when it unloads', async () => {
47
+ const ctx = await system()
48
+ const fiber = ctx.plugin({ name: 'owner', inject: ['state'], apply(c: Context) { c.state.cell('owner.count', 1) } })
49
+ await settle()
50
+ expect(ctx.state.keys()).toEqual(['owner.count'])
51
+ expect(ctx.state.find<number>('owner.count')?.get()).toBe(1)
52
+ await fiber.dispose()
53
+ await settle()
54
+ expect(ctx.state.keys()).toEqual([])
55
+ expect(ctx.state.find('owner.count')).toBeUndefined()
56
+ })
57
+
58
+ it('rejects a bad or duplicate key', async () => {
59
+ const ctx = await system()
60
+ ctx.state.cell('ok.key', 0)
61
+ expect(() => ctx.state.cell('ok.key', 1)).toThrow(/already in use/)
62
+ expect(() => ctx.state.cell('Bad Key', 1)).toThrow(/dotted lowercase/)
63
+ expect(() => ctx.state.derive('ok.key', [], () => 1)).toThrow(/already in use/)
64
+ })
65
+
66
+ it('derives a cell from others and detaches it when its owner unloads', async () => {
67
+ const ctx = await system()
68
+ const a = ctx.state.cell<number>('n.a', 1)
69
+ const fiber = ctx.plugin({ name: 'deriver', inject: ['state'], apply(c: Context) { c.state.derive('n.double', [a] as const, x => x * 2) } })
70
+ await settle()
71
+ expect(ctx.state.find<number>('n.double')?.get()).toBe(2)
72
+ a.set(5)
73
+ expect(ctx.state.find<number>('n.double')?.get()).toBe(10)
74
+ await fiber.dispose()
75
+ await settle()
76
+ expect(ctx.state.find('n.double')).toBeUndefined()
77
+ })
78
+
79
+ it('serves keys and values as JSON on the router, read-only', async () => {
80
+ const ctx = await system()
81
+ ctx.state.cell('app.title', 'hello')
82
+ const keys = await ctx.router.fetch(new Request('http://acryl.test/api/state'))
83
+ expect(await keys.json()).toEqual({ keys: ['app.title'] })
84
+ const one = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.title'))
85
+ expect(await one.json()).toEqual({ key: 'app.title', value: 'hello' })
86
+ expect((await ctx.router.fetch(new Request('http://acryl.test/api/state/none'))).status).toBe(404)
87
+ expect((await ctx.router.fetch(new Request('http://acryl.test/api/state/app.title', { method: 'PUT', body: '"x"' }))).status).toBe(405)
88
+ expect(ctx.state.find('app.title')?.get()).toBe('hello')
89
+ })
90
+
91
+ it('streams the value now and every change as server-sent events, and ends when the caller aborts', async () => {
92
+ const ctx = await system()
93
+ const cell = ctx.state.cell<number>('app.count', 0)
94
+ const controller = new AbortController()
95
+ const response = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.count/events', { signal: controller.signal }))
96
+ expect(response.headers.get('content-type')).toContain('text/event-stream')
97
+ const reading = events(response, 3)
98
+ await settle(5)
99
+ cell.set(1)
100
+ cell.set(2)
101
+ expect(await reading).toEqual(['event: value\ndata: 0', 'event: value\ndata: 1', 'event: value\ndata: 2'])
102
+ controller.abort()
103
+ await settle(5)
104
+ // After the abort the stream no longer holds a subscription on the cell.
105
+ let notified = 0
106
+ const probe = ctx.state.find<number>('app.count')!.subscribe(() => { notified += 1 })
107
+ cell.set(3)
108
+ probe()
109
+ expect(notified).toBe(1)
110
+ })
111
+
112
+ it('closes every open stream when the router is disposed', async () => {
113
+ const ctx = await system()
114
+ ctx.state.cell<number>('app.count', 0)
115
+ const routerFiber = [...[...ctx.registry.values()].find(runtime => runtime.name === routerName)!.fibers][0]!
116
+ const response = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.count/events'))
117
+ const reader = response.body!.getReader()
118
+ await reader.read()
119
+ const ended = reader.read()
120
+ await routerFiber.dispose()
121
+ await expect(ended).resolves.toMatchObject({ done: true })
122
+ })
123
+
124
+ it('works with no router: cells and subscriptions only, no routes', async () => {
125
+ const ctx = await system(false)
126
+ const cell = ctx.state.cell<string>('plain.value', 'a')
127
+ const seen: string[] = []
128
+ cell.subscribe(value => seen.push(value))
129
+ cell.set('b')
130
+ expect(seen).toEqual(['b'])
131
+ })
132
+ })
@@ -0,0 +1,20 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-state",
5
+ "commit": "11fed295161c0282d144b5ac62616988b58d8189"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 4,
13
+ "digest": "sha256:528bdc35dbbe22dfae8822fe054cfbfccae9554b9e06089dd7e269f44e27add4",
14
+ "testsNotRun": {
15
+ "tests/react.spec.ts": "needs react and @testing-library/react, which generated apps do not install"
16
+ },
17
+ "notice": [
18
+ "LICENSE"
19
+ ]
20
+ }
@@ -0,0 +1,152 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from './memory.ts'
3
+ import type {} from './model.ts'
4
+ import type {} from './tools.ts'
5
+
6
+ /** One tool call made during a run. */
7
+ export interface ToolCall {
8
+ readonly tool: string
9
+ readonly input: unknown
10
+ readonly result?: unknown
11
+ readonly error?: string
12
+ /** The thinking model's reasoning for this call, kept so the next step can send it back. */
13
+ readonly reasoning?: string
14
+ }
15
+
16
+ /** How a run ended, with every tool call it made. `task` is its durable record's id when the app records tasks. */
17
+ export type RunResult = (
18
+ | { readonly status: 'completed'; readonly output: string }
19
+ | { readonly status: 'budget_exceeded' }
20
+ | { readonly status: 'cancelled' }
21
+ ) & { readonly steps: number; readonly trace: readonly ToolCall[]; readonly task?: string }
22
+
23
+ export interface Agent {
24
+ run(question: string): Promise<RunResult>
25
+ }
26
+
27
+ /** The step budget of one run, provided inside the run's own fiber. */
28
+ export interface Budget {
29
+ steps: number
30
+ readonly limit: number
31
+ }
32
+
33
+ export interface AgentConfig {
34
+ /** Steps per run before it stops. */
35
+ steps?: number
36
+ }
37
+
38
+ /**
39
+ * The part of a task recorder the agent uses. Optional: when an app mounts the `tasks` plugin, each run is a durable task and each
40
+ * tool call a task it owns; without it, runs work the same and leave no record.
41
+ */
42
+ interface TaskRecorder {
43
+ start(
44
+ kind: string,
45
+ input: unknown,
46
+ options?: { parent?: string },
47
+ ): {
48
+ readonly id: string
49
+ readonly signal: AbortSignal
50
+ progress(detail: unknown): void
51
+ complete(result: unknown, detail?: unknown): void
52
+ fail(error: unknown, detail?: unknown): void
53
+ }
54
+ }
55
+
56
+ declare module '@deepseek-ai/cordis' {
57
+ interface Context {
58
+ agent: Agent
59
+ budget: Budget
60
+ }
61
+ }
62
+
63
+ const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
64
+
65
+ /**
66
+ * The agent. Every run is its own child fiber that holds the run's budget and is disposed when the run ends, however it ends: a
67
+ * scope per task, written with plain Cordis plugins. A tool that throws does not end the run: the model sees the error as the result.
68
+ * When the app records tasks, the run's record is written before the answer is returned, and cancelling it stops the run.
69
+ */
70
+ export const agent = {
71
+ name: 'agent',
72
+ inject: ['model', 'tools', 'memory'],
73
+ apply(ctx: Context, config?: AgentConfig) {
74
+ const limit = config?.steps ?? 6
75
+ let runs = 0
76
+ ctx.provide('agent', {
77
+ async run(question) {
78
+ runs += 1
79
+ const trace: ToolCall[] = []
80
+ const recorder = (ctx.get as (name: string) => unknown)('tasks') as TaskRecorder | undefined
81
+ const record = recorder?.start('agent-run', { question })
82
+ // Assigned inside the run's fiber; `as` keeps TypeScript from narrowing it to this first value.
83
+ let result = { status: 'budget_exceeded', steps: 0, trace } as RunResult
84
+ const task = ctx.plugin({
85
+ name: `task-${runs}`,
86
+ inject: ['model', 'tools', 'memory'],
87
+ async apply(scope: Context) {
88
+ scope.provide('budget', { steps: 0, limit } satisfies Budget)
89
+ const budget = scope.budget
90
+ let toolResult: unknown
91
+ while (budget.steps < budget.limit) {
92
+ if (record?.signal.aborted) {
93
+ result = { status: 'cancelled', steps: budget.steps, trace }
94
+ return
95
+ }
96
+ const step = await scope.model.complete({
97
+ question,
98
+ step: budget.steps,
99
+ toolResult,
100
+ trace: [...trace],
101
+ tools: scope.tools.list(),
102
+ })
103
+ budget.steps += 1
104
+ if (step.tool === undefined) {
105
+ scope.memory.remember(`${question} => ${step.text}`)
106
+ result = { status: 'completed', output: step.text, steps: budget.steps, trace }
107
+ return
108
+ }
109
+ const call = record
110
+ ? recorder?.start(`tool:${step.tool}`, step.input, { parent: record.id })
111
+ : undefined
112
+ try {
113
+ toolResult = await scope.tools.call(step.tool, step.input)
114
+ trace.push({
115
+ tool: step.tool,
116
+ input: step.input,
117
+ result: toolResult,
118
+ ...(step.reasoning !== undefined ? { reasoning: step.reasoning } : {}),
119
+ })
120
+ call?.complete(toolResult)
121
+ } catch (error) {
122
+ toolResult = { error: message(error) }
123
+ trace.push({
124
+ tool: step.tool,
125
+ input: step.input,
126
+ error: message(error),
127
+ ...(step.reasoning !== undefined ? { reasoning: step.reasoning } : {}),
128
+ })
129
+ call?.fail(error)
130
+ }
131
+ record?.progress({ steps: budget.steps, trace })
132
+ }
133
+ result = { status: 'budget_exceeded', steps: budget.steps, trace }
134
+ },
135
+ })
136
+ try {
137
+ await task.await()
138
+ } catch (error) {
139
+ record?.fail(error, { trace })
140
+ throw error
141
+ } finally {
142
+ await task.dispose()
143
+ }
144
+ if (result.status === 'completed')
145
+ record?.complete(result.output, { steps: result.steps, trace })
146
+ else if (result.status === 'budget_exceeded')
147
+ record?.fail('the run used its whole step budget', { steps: result.steps, trace })
148
+ return record ? { ...result, task: record.id } : result
149
+ },
150
+ } satisfies Agent)
151
+ },
152
+ }
@@ -0,0 +1,20 @@
1
+ /** The offline agent: a scripted model, a tool registry, in-process memory, and one child fiber per run. */
2
+ export {
3
+ type Agent,
4
+ type AgentConfig,
5
+ agent,
6
+ type Budget,
7
+ type RunResult,
8
+ type ToolCall,
9
+ } from './agent.ts'
10
+ export { type Memory, memory } from './memory.ts'
11
+ export {
12
+ type Model,
13
+ type ModelInput,
14
+ model,
15
+ type Rule,
16
+ type ScriptedModel,
17
+ type Step,
18
+ scripted,
19
+ } from './model.ts'
20
+ export { type Tool, type Tools, tools } from './tools.ts'
@@ -0,0 +1,24 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+
3
+ /** Long-term memory for the agent. */
4
+ export interface Memory {
5
+ remember(text: string): void
6
+ recall(): string[]
7
+ size(): number
8
+ }
9
+
10
+ declare module '@deepseek-ai/cordis' {
11
+ interface Context {
12
+ memory: Memory
13
+ }
14
+ }
15
+
16
+ /** In-process memory. Replace it with a plugin backed by storage when you need persistence. */
17
+ export function memory(ctx: Context) {
18
+ const items: string[] = []
19
+ ctx.provide('memory', {
20
+ remember: (text) => void items.push(text),
21
+ recall: () => [...items],
22
+ size: () => items.length,
23
+ } satisfies Memory)
24
+ }
@@ -0,0 +1,79 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+
3
+ /** One model step: final text, or a tool to call with its input. `reasoning` is a thinking model's reasoning for the call; it must be given back on the next step, so the run keeps it. */
4
+ export type Step =
5
+ | { readonly text: string; readonly tool?: undefined }
6
+ | { readonly tool: string; readonly input: unknown; readonly reasoning?: string }
7
+
8
+ /** What a model sees at each step of a run. */
9
+ export interface ModelInput {
10
+ readonly question: string
11
+ /** 0 for the first step of a run. */
12
+ readonly step: number
13
+ /** The previous tool's result, if the previous step called one. */
14
+ readonly toolResult?: unknown
15
+ /** Every tool call of this run so far, in order: a real model rebuilds the conversation from it. */
16
+ readonly trace?: ReadonlyArray<{
17
+ readonly tool: string
18
+ readonly input: unknown
19
+ readonly result?: unknown
20
+ readonly error?: string
21
+ readonly reasoning?: string
22
+ }>
23
+ /** The tools the model may call. */
24
+ readonly tools?: ReadonlyArray<{ readonly name: string; readonly description: string }>
25
+ }
26
+
27
+ /** What the agent needs from a model. Replace this plugin with a real provider when you have one. */
28
+ export interface Model {
29
+ complete(input: ModelInput): Promise<Step>
30
+ }
31
+
32
+ /** A rule of the scripted model: a step for the input, or `undefined` when it does not apply. */
33
+ export type Rule = (input: ModelInput) => Step | undefined
34
+
35
+ /** The deterministic model. Other plugins teach it rules for their commands, so whole flows are tested without a network. */
36
+ export interface ScriptedModel extends Model {
37
+ /** Add a rule, tried before the built-in ones. Returns the function that removes it. */
38
+ teach(rule: Rule): () => void
39
+ }
40
+
41
+ declare module '@deepseek-ai/cordis' {
42
+ interface Context {
43
+ model: Model
44
+ }
45
+ }
46
+
47
+ const builtIn: Rule[] = [
48
+ ({ toolResult, step }) =>
49
+ step > 0 ? { text: `The answer is ${String(toolResult)}.` } : undefined,
50
+ ({ question }) => {
51
+ const sum = /^add\s+(-?\d+)\s+(-?\d+)$/.exec(question.trim())
52
+ return sum ? { tool: 'add', input: [Number(sum[1]), Number(sum[2])] } : undefined
53
+ },
54
+ ]
55
+
56
+ /** A deterministic model: no keys, no network, the same answer every time. */
57
+ export function model(ctx: Context) {
58
+ const taught: Rule[] = []
59
+ ctx.provide('model', {
60
+ teach(rule) {
61
+ taught.push(rule)
62
+ return () => void taught.splice(taught.indexOf(rule), 1)
63
+ },
64
+ complete(input) {
65
+ for (const rule of [...taught, ...builtIn]) {
66
+ const step = rule(input)
67
+ if (step) return Promise.resolve(step)
68
+ }
69
+ return Promise.resolve({ text: `You said: ${input.question}` })
70
+ },
71
+ } satisfies ScriptedModel)
72
+ }
73
+
74
+ /** The scripted model, when the app runs one (a real provider has no `teach`). */
75
+ export function scripted(model: Model | undefined): ScriptedModel | undefined {
76
+ return model && typeof (model as Partial<ScriptedModel>).teach === 'function'
77
+ ? (model as ScriptedModel)
78
+ : undefined
79
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "agent",
3
+ "description": "An offline agent: deterministic model, tools, memory, and a child fiber per run that holds the run's step budget.",
4
+ "requires": [],
5
+ "inject": ["model", "tools", "memory"],
6
+ "provides": ["agent", "model", "tools", "memory"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ }
10
+ }