acrylblends 0.7.8 → 0.8.1
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.
- package/dist/index.d.ts +13 -5
- package/dist/index.js +72 -13
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +2 -2
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +1 -1
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +2 -1
- package/docs/framework/upstream.json +3 -3
- package/lib/node-server.js +1 -0
- package/package.json +1 -1
- package/plugins/acryl-db/plugin.json +6 -1
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +1 -1
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +6 -1
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/upstream.json +1 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/resolve.ts +16 -2
- package/plugins/tsconfig.json +6 -0
- package/shared/DEFAULTS.md +11 -6
- package/shared/SKILL.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/src/app.ts +18 -2
- package/templates/agent/src/routes.ts +14 -1
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +10 -4
- package/templates/agent/test/app.test.ts +29 -8
- package/templates/agent/test/builder.test.ts +34 -23
- package/templates/agent/test/model.test.ts +24 -19
- package/templates/blank/src/app.ts +25 -5
- package/templates/blank/test/app.test.ts +28 -9
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +32 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +107 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +12 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +29 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +95 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +11 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +32 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +107 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +12 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/src/app.ts +27 -7
- package/templates/todo/test/todo.test.ts +47 -22
- package/templates/todo/upstream.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door's verbs, one implementation for every face: `brief`, `look`, `act`, `plugin list|add|remove|reload` and `tool list|call`
|
|
3
|
+
* (`verbs.ts` lists them; the operator client, MCP and the built-in chat all call these). Each verb takes plain JSON arguments,
|
|
4
|
+
* returns a typed value and a text rendering, and fails with a {@link DoorError} or a {@link UiControlError} carrying a code.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
9
|
+
import { parseUiRequest, UiControlError, type AuditEntry, type UiActionResult, type UiRequest, type UiResult, type UiSnapshot } from '../page/protocol.ts'
|
|
10
|
+
import { renderSnapshot } from '../page/snapshot-text.ts'
|
|
11
|
+
import type { AuditLog } from './audit.ts'
|
|
12
|
+
import type { UiChannel } from './channel.ts'
|
|
13
|
+
import { VERB_SPECS, VERBS } from './verbs.ts'
|
|
14
|
+
|
|
15
|
+
export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed'
|
|
16
|
+
|
|
17
|
+
export class DoorError extends Error {
|
|
18
|
+
readonly code: DoorErrorCode
|
|
19
|
+
|
|
20
|
+
constructor(code: DoorErrorCode, message: string) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.code = code
|
|
23
|
+
this.name = 'DoorError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The slice of acrylblends' `extensions` service the plugin verbs use, declared here so the door does not depend on that plugin. */
|
|
28
|
+
export interface ExtensionsPort {
|
|
29
|
+
install(source: string): Promise<unknown>
|
|
30
|
+
remove(name: string): Promise<unknown>
|
|
31
|
+
reload(): Promise<unknown>
|
|
32
|
+
list(): readonly unknown[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
|
|
36
|
+
export interface ToolsPort {
|
|
37
|
+
/** @returns the function that removes the tool. @throws when the name is taken. */
|
|
38
|
+
register(name: string, tool: { readonly description: string; run(input: unknown): unknown }): () => void
|
|
39
|
+
list(): ReadonlyArray<{ readonly name: string; readonly description: string }>
|
|
40
|
+
call(name: string, input: unknown): Promise<unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DoorDeps {
|
|
44
|
+
readonly channel: UiChannel
|
|
45
|
+
readonly audit: AuditLog
|
|
46
|
+
/** The app's name and folder. */
|
|
47
|
+
readonly app: { readonly name: string; readonly root: string }
|
|
48
|
+
/** Read at each call: the services may come and go while the door runs. */
|
|
49
|
+
readonly extensions: () => ExtensionsPort | undefined
|
|
50
|
+
readonly tools: () => ToolsPort | undefined
|
|
51
|
+
/** The app tools the door offers; anything else is refused before it reaches the registry. */
|
|
52
|
+
readonly expose: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What one verb answered: the value for `--json` and programs, the text for an agent reading a terminal. */
|
|
56
|
+
export interface VerbAnswer {
|
|
57
|
+
readonly value: unknown
|
|
58
|
+
readonly text: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The files an agent should read first, when the app has them. */
|
|
62
|
+
const SELF_KNOWLEDGE = ['AGENTS.md', 'CLAUDE.md', '.agents/skills', '.claude/skills', 'docs', 'plugins']
|
|
63
|
+
|
|
64
|
+
export class Door {
|
|
65
|
+
private readonly deps: DoorDeps
|
|
66
|
+
|
|
67
|
+
constructor(deps: DoorDeps) {
|
|
68
|
+
this.deps = deps
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Run one verb. @throws DoorError or UiControlError. */
|
|
72
|
+
async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
|
|
73
|
+
const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
|
|
74
|
+
switch (verb) {
|
|
75
|
+
case 'brief': return this.brief()
|
|
76
|
+
case 'look': {
|
|
77
|
+
const snapshot = await this.look(input, signal)
|
|
78
|
+
return { value: snapshot, text: renderSnapshot(snapshot, cursor => `run look --cursor ${String(cursor)}`) }
|
|
79
|
+
}
|
|
80
|
+
case 'act': {
|
|
81
|
+
const result = await this.act(input, signal)
|
|
82
|
+
return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
|
|
83
|
+
}
|
|
84
|
+
case 'plugin-list': return this.plugin(extensions => extensions.list())
|
|
85
|
+
case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
|
|
86
|
+
case 'plugin-remove': {
|
|
87
|
+
const name = input.name
|
|
88
|
+
if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
|
|
89
|
+
return this.plugin(extensions => extensions.remove(name))
|
|
90
|
+
}
|
|
91
|
+
case 'plugin-reload': return this.plugin(extensions => extensions.reload())
|
|
92
|
+
case 'tool-list': {
|
|
93
|
+
const offered = this.offeredTools()
|
|
94
|
+
return { value: offered, text: offered.length === 0 ? 'no app tools are offered through the door (tools.expose is empty)' : offered.map(tool => `${tool.name}: ${tool.description}`).join('\n') }
|
|
95
|
+
}
|
|
96
|
+
case 'tool-call': return this.callTool(input)
|
|
97
|
+
default: throw new DoorError('unknown-verb', `unknown verb ${JSON.stringify(verb)}; the verbs are ${VERBS.join(', ')}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
brief(): VerbAnswer {
|
|
102
|
+
const { name, root } = this.deps.app
|
|
103
|
+
const read = SELF_KNOWLEDGE.filter(path => existsSync(resolve(root, path)))
|
|
104
|
+
const value = {
|
|
105
|
+
app: name,
|
|
106
|
+
root,
|
|
107
|
+
read,
|
|
108
|
+
page: this.deps.channel.connected ? 'open' : 'not open',
|
|
109
|
+
plugins: this.deps.extensions() === undefined ? 'unavailable' : 'available',
|
|
110
|
+
verbs: VERBS.map(verb => VERB_SPECS[verb].usage),
|
|
111
|
+
}
|
|
112
|
+
const text = [
|
|
113
|
+
`App ${name} in ${root}.`,
|
|
114
|
+
read.length === 0 ? 'No AGENTS.md or skill in the folder.' : `Read first: ${read.join(', ')}.`,
|
|
115
|
+
`Page: ${value.page}${value.page === 'not open' ? ' (open the app in a browser to look and act)' : ''}.`,
|
|
116
|
+
`Live plugins: ${value.plugins}.`,
|
|
117
|
+
'Verbs:',
|
|
118
|
+
...value.verbs.map(line => ` ${line}`),
|
|
119
|
+
].join('\n')
|
|
120
|
+
return { value, text }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
|
|
124
|
+
const request = this.parse({ ...input, op: 'snapshot' })
|
|
125
|
+
return this.callPage('look', request, signal) as Promise<UiSnapshot>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
|
|
129
|
+
if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
|
|
130
|
+
return this.callPage(`act ${String(input.op)}`, this.parse(input), signal) as Promise<UiActionResult>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private parse(input: Record<string, unknown>): UiRequest {
|
|
134
|
+
try {
|
|
135
|
+
return parseUiRequest(input)
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
throw new DoorError('invalid', cause instanceof Error ? cause.message : 'invalid request')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
|
|
142
|
+
private async callPage(verb: string, request: UiRequest, signal: AbortSignal): Promise<UiResult> {
|
|
143
|
+
const approval: AuditEntry['approval'] = 'token'
|
|
144
|
+
try {
|
|
145
|
+
const value = await this.deps.channel.call(request, { signal, ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
|
|
146
|
+
const target = 'target' in value ? value.target : undefined
|
|
147
|
+
this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), outcome: 'ok', approval })
|
|
148
|
+
return value
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
const code = cause instanceof UiControlError ? cause.code : 'failed'
|
|
151
|
+
const refused = cause instanceof UiControlError && ['protected', 'sensitive', 'killed', 'invalid', 'stale-ref', 'unknown-ref', 'not-actionable'].includes(cause.code)
|
|
152
|
+
this.deps.audit.record({ at: new Date().toISOString(), tool: verb, outcome: refused ? 'refused' : 'failed', detail: code, approval })
|
|
153
|
+
throw cause
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async plugin(run: (extensions: ExtensionsPort) => unknown): Promise<VerbAnswer> {
|
|
158
|
+
const extensions = this.deps.extensions()
|
|
159
|
+
if (extensions === undefined) throw new DoorError('unavailable', 'this app has no extensions plugin, so plugins cannot be installed while it runs')
|
|
160
|
+
const value = await run(extensions)
|
|
161
|
+
return { value, text: JSON.stringify(value, null, 2) }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A folder named by an agent, resolved from the app folder; it must be inside the app. */
|
|
165
|
+
private insideApp(folder: unknown): string {
|
|
166
|
+
if (typeof folder !== 'string' || folder === '') throw new DoorError('invalid', 'plugin add needs a folder, e.g. plugins/pomodoro')
|
|
167
|
+
const root = this.deps.app.root
|
|
168
|
+
const full = resolve(root, folder)
|
|
169
|
+
const path = relative(root, full)
|
|
170
|
+
if (path === '' || path.startsWith('..') || isAbsolute(path)) throw new DoorError('outside-app', `${folder} is not a folder inside the app (${root})`)
|
|
171
|
+
return full
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private offeredTools(): Array<{ name: string; description: string }> {
|
|
175
|
+
const tools = this.deps.tools()
|
|
176
|
+
if (tools === undefined) return []
|
|
177
|
+
const allowed = new Set(this.deps.expose)
|
|
178
|
+
return tools.list().filter(tool => allowed.has(tool.name)).map(({ name, description }) => ({ name, description }))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async callTool(input: Record<string, unknown>): Promise<VerbAnswer> {
|
|
182
|
+
const name = input.tool
|
|
183
|
+
if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'tool call needs --tool <name>')
|
|
184
|
+
if (!this.deps.expose.includes(name)) throw new DoorError('not-exposed', `${name} is not offered through the door`)
|
|
185
|
+
const tools = this.deps.tools()
|
|
186
|
+
if (tools === undefined || !tools.list().some(tool => tool.name === name)) throw new DoorError('unavailable', `${name} is not available right now`)
|
|
187
|
+
let value: unknown
|
|
188
|
+
try {
|
|
189
|
+
value = await tools.call(name, input.args ?? {})
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
throw new DoorError('failed', cause instanceof Error ? cause.message : String(cause))
|
|
192
|
+
}
|
|
193
|
+
return { value, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every door route shares (moved from `acryl-agent-control`, spec 041). Two kinds of caller reach it, each with its own guard: the
|
|
3
|
+
* app's own page (same origin, on a loopback address), and an operator on this machine (`.acryl/bin/acryl`, an agent's shell) presenting
|
|
4
|
+
* this run's secret.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
8
|
+
import { Acryl, type Context, type MiddlewareHandler } from '../../../acryl-router/src/index.ts'
|
|
9
|
+
import { bodyLimit, loopbackAuthority, loopbackOnly, methodNotAllowed } from '../../../acryl-router/src/middleware/index.ts'
|
|
10
|
+
|
|
11
|
+
/** Every door route lives below this path. */
|
|
12
|
+
export const DOOR_PREFIX = '/api/acryl-door'
|
|
13
|
+
|
|
14
|
+
/** The cap on a JSON request body. */
|
|
15
|
+
export const MAX_BODY_BYTES = 64 * 1024
|
|
16
|
+
|
|
17
|
+
export type ReportError = (operation: string, cause: unknown) => void
|
|
18
|
+
|
|
19
|
+
/** The JSON body of a refusal. */
|
|
20
|
+
export function error(message: string): { readonly error: string } {
|
|
21
|
+
return { error: message }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The app's own page, on a loopback address, and nothing else: another origin, another port and a script on the machine are refused. */
|
|
25
|
+
export const ownPage: MiddlewareHandler = loopbackOnly({ callers: 'own-page' })
|
|
26
|
+
|
|
27
|
+
/** A constant-time comparison of a `Bearer` header with the secret. */
|
|
28
|
+
export function tokenMatches(header: string | undefined, secret: string): boolean {
|
|
29
|
+
const presented = header?.startsWith('Bearer ') === true ? header.slice('Bearer '.length) : ''
|
|
30
|
+
const a = Buffer.from(presented)
|
|
31
|
+
const b = Buffer.from(secret)
|
|
32
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Only an operator on this machine: a loopback peer addressing this server's own port (`loopbackAuthority`), presenting this run's
|
|
37
|
+
* secret. Anything else is 403 `forbidden`, which the client reads as "refused: the app restarted and the secret rotated?".
|
|
38
|
+
*/
|
|
39
|
+
export function operatorOnly(secret: string): MiddlewareHandler {
|
|
40
|
+
return async (c, next) => {
|
|
41
|
+
if (loopbackAuthority(c) === undefined || !tokenMatches(c.req.header('authorization'), secret)) return c.json(error('forbidden'), 403)
|
|
42
|
+
await next()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The headers every private answer carries, set after the handler so a refusal carries them too. */
|
|
47
|
+
const privateHeaders: MiddlewareHandler = async (c, next) => {
|
|
48
|
+
await next()
|
|
49
|
+
c.header('cache-control', 'no-store')
|
|
50
|
+
c.header('x-content-type-options', 'nosniff')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The door's routes as one app: each part's routes, every answer with the private headers, and a known path asked with another method
|
|
55
|
+
* answered 405 with its `Allow` before any guard runs. The middleware is scoped to the prefix, so it never wraps another plugin's routes.
|
|
56
|
+
*/
|
|
57
|
+
export function privateApi(prefix: string, ...routes: readonly Acryl[]): Acryl {
|
|
58
|
+
const app = new Acryl()
|
|
59
|
+
app.use(`${prefix}/*`, privateHeaders, methodNotAllowed({
|
|
60
|
+
app,
|
|
61
|
+
onMethodNotAllowed: (c, allowed) => c.json(error('method not allowed'), 405, { allow: allowed.join(', ') }),
|
|
62
|
+
}))
|
|
63
|
+
for (const part of routes) app.route('/', part)
|
|
64
|
+
return app
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Refuse a body over {@link MAX_BODY_BYTES} with 413 before the handler reads it. */
|
|
68
|
+
export const jsonBody: MiddlewareHandler = bodyLimit({ maxSize: MAX_BODY_BYTES, onError: c => c.json(error('request body is too large'), 413) })
|
|
69
|
+
|
|
70
|
+
/** Returned by {@link readJson} for a body that is not JSON. */
|
|
71
|
+
export const INVALID_JSON = Symbol('invalid JSON')
|
|
72
|
+
|
|
73
|
+
/** The parsed body, or {@link INVALID_JSON}. */
|
|
74
|
+
export async function readJson(c: Context): Promise<unknown> {
|
|
75
|
+
try {
|
|
76
|
+
return await c.req.json<unknown>()
|
|
77
|
+
} catch {
|
|
78
|
+
return INVALID_JSON
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door over MCP: `POST <prefix>/mcp`, streamable HTTP in its request/response form (one JSON-RPC message or a batch in, its JSON
|
|
3
|
+
* answer out, 202 for notifications only; no event stream is offered). Each verb is one tool, named as the verb, with the arguments the
|
|
4
|
+
* operator client sends. The same guard as the operator's routes: loopback, this run's secret.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Acryl } from '../../../acryl-router/src/index.ts'
|
|
8
|
+
import { UiControlError } from '../page/protocol.ts'
|
|
9
|
+
import type { Door } from './door.ts'
|
|
10
|
+
import { DoorError } from './door.ts'
|
|
11
|
+
import { INVALID_JSON, jsonBody, operatorOnly, readJson, type ReportError } from './http.ts'
|
|
12
|
+
import { VERB_SPECS, VERBS, type Verb } from './verbs.ts'
|
|
13
|
+
|
|
14
|
+
const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'] as const
|
|
15
|
+
const SERVER_INFO = { name: 'acryl-door', version: '1' } as const
|
|
16
|
+
|
|
17
|
+
interface JsonRpcRequest { readonly jsonrpc: '2.0'; readonly id?: string | number | null; readonly method: string; readonly params?: unknown }
|
|
18
|
+
|
|
19
|
+
function isRequest(value: unknown): value is JsonRpcRequest {
|
|
20
|
+
return typeof value === 'object' && value !== null && (value as { jsonrpc?: unknown }).jsonrpc === '2.0' && typeof (value as { method?: unknown }).method === 'string'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rpcError = (id: JsonRpcRequest['id'], code: number, message: string) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } })
|
|
24
|
+
const result = (id: JsonRpcRequest['id'], value: unknown) => ({ jsonrpc: '2.0', id: id ?? null, result: value })
|
|
25
|
+
|
|
26
|
+
/** The verbs as MCP tools. */
|
|
27
|
+
export function mcpTools(): Array<{ name: Verb; description: string; inputSchema: unknown }> {
|
|
28
|
+
return VERBS.map(verb => ({ name: verb, description: VERB_SPECS[verb].description, inputSchema: VERB_SPECS[verb].arguments }))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isVerb = (name: unknown): name is Verb => typeof name === 'string' && (VERBS as readonly string[]).includes(name)
|
|
32
|
+
|
|
33
|
+
/** One JSON-RPC message; `undefined` for a notification (no answer owed). */
|
|
34
|
+
async function answer(message: JsonRpcRequest, door: Door, signal: AbortSignal): Promise<Record<string, unknown> | undefined> {
|
|
35
|
+
const isNotification = message.id === undefined
|
|
36
|
+
switch (message.method) {
|
|
37
|
+
case 'initialize': {
|
|
38
|
+
const asked = (message.params as { protocolVersion?: unknown } | undefined)?.protocolVersion
|
|
39
|
+
const protocolVersion = (SUPPORTED_PROTOCOLS as readonly unknown[]).includes(asked) ? asked : SUPPORTED_PROTOCOLS[0]
|
|
40
|
+
return result(message.id, { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: SERVER_INFO })
|
|
41
|
+
}
|
|
42
|
+
case 'ping':
|
|
43
|
+
return isNotification ? undefined : result(message.id, {})
|
|
44
|
+
case 'tools/list':
|
|
45
|
+
return result(message.id, { tools: mcpTools() })
|
|
46
|
+
case 'tools/call': {
|
|
47
|
+
// MCP allows extra fields on params (`_meta`, a progress token); only `name` and `arguments` mean anything here.
|
|
48
|
+
const params = typeof message.params === 'object' && message.params !== null ? message.params as Record<string, unknown> : {}
|
|
49
|
+
if (!isVerb(params.name)) return rpcError(message.id, -32602, `unknown tool ${JSON.stringify(params.name)}; the tools are ${VERBS.join(', ')}`)
|
|
50
|
+
try {
|
|
51
|
+
const verbAnswer = await door.run(params.name, params.arguments ?? {}, signal)
|
|
52
|
+
return result(message.id, { content: [{ type: 'text', text: verbAnswer.text }], structuredContent: { value: verbAnswer.value }, isError: false })
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
// A verb that failed is a tool result the model reads, with the same code the operator client prints.
|
|
55
|
+
if (cause instanceof DoorError || cause instanceof UiControlError) return result(message.id, { content: [{ type: 'text', text: `${cause.code}: ${cause.message}` }], isError: true })
|
|
56
|
+
throw cause
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
default:
|
|
60
|
+
return isNotification ? undefined : rpcError(message.id, -32601, `method not found: ${message.method}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mcpRoutes(prefix: string, secret: string, door: Door, reportError: ReportError): Acryl {
|
|
65
|
+
return new Acryl().post(`${prefix}/mcp`, operatorOnly(secret), jsonBody, async (c) => {
|
|
66
|
+
const body = await readJson(c)
|
|
67
|
+
if (body === INVALID_JSON) return c.json(rpcError(null, -32700, 'parse error'), 400)
|
|
68
|
+
try {
|
|
69
|
+
const messages = Array.isArray(body) ? body : [body]
|
|
70
|
+
const answers: Record<string, unknown>[] = []
|
|
71
|
+
for (const message of messages) {
|
|
72
|
+
if (!isRequest(message)) { answers.push(rpcError(null, -32600, 'invalid request')); continue }
|
|
73
|
+
const response = await answer(message, door, c.req.raw.signal)
|
|
74
|
+
if (response !== undefined) answers.push(response)
|
|
75
|
+
}
|
|
76
|
+
if (answers.length === 0) return c.body(null, 202)
|
|
77
|
+
return c.json(Array.isArray(body) ? answers : (answers[0] as object))
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
reportError('answer an MCP request', cause)
|
|
80
|
+
return c.json(rpcError(null, -32603, 'internal error'), 500)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's routes: an agent's shell on this machine, through `.acryl/bin/acryl`. Loopback only, with this run's secret.
|
|
3
|
+
*
|
|
4
|
+
* `POST <prefix>/call` takes `{ verb, args }` and answers {@link CallResponse}; `GET <prefix>/verbs` lists the verbs. A caller that goes
|
|
5
|
+
* away aborts its call through the request's signal.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Acryl } from '../../../acryl-router/src/index.ts'
|
|
9
|
+
import { UiControlError } from '../page/protocol.ts'
|
|
10
|
+
import { Door, DoorError } from './door.ts'
|
|
11
|
+
import { error, INVALID_JSON, jsonBody, operatorOnly, readJson, type ReportError } from './http.ts'
|
|
12
|
+
import { VERBS } from './verbs.ts'
|
|
13
|
+
|
|
14
|
+
export type CallResponse =
|
|
15
|
+
| { readonly ok: true; readonly value: unknown; readonly text: string }
|
|
16
|
+
| { readonly ok: false; readonly code: string; readonly message: string }
|
|
17
|
+
|
|
18
|
+
export function operatorRoutes(prefix: string, secret: string, door: Door, reportError: ReportError): Acryl {
|
|
19
|
+
return new Acryl()
|
|
20
|
+
.get(`${prefix}/verbs`, operatorOnly(secret), c => c.json({ verbs: VERBS }))
|
|
21
|
+
.post(`${prefix}/call`, operatorOnly(secret), jsonBody, async (c) => {
|
|
22
|
+
const body = await readJson(c)
|
|
23
|
+
if (body === INVALID_JSON || typeof body !== 'object' || body === null || typeof (body as { verb?: unknown }).verb !== 'string') {
|
|
24
|
+
return c.json({ ok: false, code: 'invalid', message: 'the body is { verb, args }' } satisfies CallResponse, 400)
|
|
25
|
+
}
|
|
26
|
+
const { verb, args } = body as { verb: string; args?: unknown }
|
|
27
|
+
try {
|
|
28
|
+
const answer = await door.run(verb, args, c.req.raw.signal)
|
|
29
|
+
return c.json({ ok: true, value: answer.value, text: answer.text } satisfies CallResponse)
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
if (cause instanceof UiControlError || cause instanceof DoorError) {
|
|
32
|
+
return c.json({ ok: false, code: cause.code, message: cause.message } satisfies CallResponse, cause.code === 'invalid' || cause.code === 'unknown-verb' ? 400 : 200)
|
|
33
|
+
}
|
|
34
|
+
reportError(`run the door verb ${verb}`, cause)
|
|
35
|
+
return c.json(error('the verb could not be run'), 500)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page channel on the router: an event stream down to the page and POST up, so it needs no WebSocket upgrade on any adapter.
|
|
3
|
+
*
|
|
4
|
+
* `GET <prefix>/channel?window=<id>&focused=0|1` is the page's stream. The Host writes each call as `event: call` and a `ping` every
|
|
5
|
+
* heartbeat, so a page that hears nothing for a while knows the stream died and reconnects. `POST <prefix>/channel/answer` carries
|
|
6
|
+
* `{ window, message }` (a result, an error or a focus change). Both are for the app's own page only (same origin, loopback).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Acryl } 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 { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
|
|
13
|
+
import type { UiChannel } from './channel.ts'
|
|
14
|
+
import { error, INVALID_JSON, jsonBody, ownPage, readJson } from './http.ts'
|
|
15
|
+
|
|
16
|
+
/** How often the Host writes a `ping`; the page gives up on a stream that is silent for three of these. */
|
|
17
|
+
export const HEARTBEAT_MS = 15_000
|
|
18
|
+
|
|
19
|
+
export interface PageRoutesOptions {
|
|
20
|
+
/** Where the channel lives, e.g. `/api/acryl-door`. */
|
|
21
|
+
readonly prefix: string
|
|
22
|
+
readonly heartbeatMs?: number
|
|
23
|
+
/** The only hostnames the page may be served from (default: every loopback name), e.g. `['127.0.0.1']` for a Host that serves only that. */
|
|
24
|
+
readonly hostnames?: readonly string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions): Acryl {
|
|
28
|
+
const heartbeatMs = options.heartbeatMs ?? HEARTBEAT_MS
|
|
29
|
+
const page = options.hostnames === undefined ? ownPage : loopbackOnly({ callers: 'own-page', hostnames: options.hostnames })
|
|
30
|
+
return new Acryl()
|
|
31
|
+
.get(`${options.prefix}/channel`, page, (c) => {
|
|
32
|
+
const windowId = c.req.query('window')
|
|
33
|
+
if (windowId === undefined || !WINDOW_ID.test(windowId)) return c.json(error('a window id is required'), 400)
|
|
34
|
+
const focused = c.req.query('focused') === '1'
|
|
35
|
+
const signal = c.req.raw.signal
|
|
36
|
+
return streamSSE(c, async (stream) => {
|
|
37
|
+
let end = (): void => {}
|
|
38
|
+
const ended = new Promise<void>((resolve) => { end = resolve })
|
|
39
|
+
stream.onAbort(end)
|
|
40
|
+
signal.addEventListener('abort', end, { once: true })
|
|
41
|
+
const write = (event: string, data: string): void => {
|
|
42
|
+
stream.writeSSE({ event, data }).catch(end)
|
|
43
|
+
}
|
|
44
|
+
const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, close: end })
|
|
45
|
+
const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
|
|
46
|
+
try {
|
|
47
|
+
write('ready', '')
|
|
48
|
+
await ended
|
|
49
|
+
} finally {
|
|
50
|
+
clearInterval(beat)
|
|
51
|
+
signal.removeEventListener('abort', end)
|
|
52
|
+
detach()
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
.post(`${options.prefix}/channel/answer`, page, jsonBody, async (c) => {
|
|
57
|
+
const body = await readJson(c)
|
|
58
|
+
if (body === INVALID_JSON || typeof body !== 'object' || body === null) return c.json(error('invalid request body'), 400)
|
|
59
|
+
const { window: windowId, message } = body as { window?: unknown; message?: unknown }
|
|
60
|
+
const parsed = parseChannelToHost(message)
|
|
61
|
+
if (typeof windowId !== 'string' || !WINDOW_ID.test(windowId) || parsed === null) return c.json(error('invalid message'), 400)
|
|
62
|
+
channel.receive(windowId, parsed)
|
|
63
|
+
return c.body(null, 204)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app folder's own command (spec 049 slice 10): `<app>/.acryl/bin/acryl` (and `acryl.cmd` on Windows) runs this door's operator
|
|
3
|
+
* client against this folder only (`--dir <app>`), with the Node that runs the app. An agent working in the folder calls it by that path,
|
|
4
|
+
* so it reaches the app running from this folder and never a user's own `acryl` on PATH or another app's door.
|
|
5
|
+
*
|
|
6
|
+
* Written each time the door starts, so a moved app or a new Node is picked up. It stays when the app stops: run then, it says that no
|
|
7
|
+
* app is running in the folder (exit 3). It carries no secret; the client reads `door.json` at each call.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { ensureStateDir } from './door-file.ts'
|
|
13
|
+
|
|
14
|
+
/** The folder of the command below the app folder's `.acryl/`. */
|
|
15
|
+
export const BIN_DIR = 'bin'
|
|
16
|
+
|
|
17
|
+
export interface ShimTarget {
|
|
18
|
+
/** The Node executable. */
|
|
19
|
+
readonly node: string
|
|
20
|
+
/** Node options the client needs, e.g. `--experimental-strip-types` when it runs from source on a Node that needs the flag. */
|
|
21
|
+
readonly nodeOptions: readonly string[]
|
|
22
|
+
/** The operator client's entry file. */
|
|
23
|
+
readonly client: string
|
|
24
|
+
/** True when `node` is Electron, which runs as Node only with `ELECTRON_RUN_AS_NODE` set. */
|
|
25
|
+
readonly electron: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A word for `sh`: single-quoted, with each `'` closed, escaped and reopened. */
|
|
29
|
+
function shWord(value: string): string {
|
|
30
|
+
return `'${value.replaceAll('\'', '\'\\\'\'')}'`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A word for `cmd.exe`: double-quoted; `%` doubled so a batch file does not expand it. A path cannot hold `"`. */
|
|
34
|
+
function cmdWord(value: string): string {
|
|
35
|
+
return `"${value.replaceAll('%', '%%')}"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function posixShim(root: string, target: ShimTarget): string {
|
|
39
|
+
const words = [target.node, ...target.nodeOptions, target.client, '--dir', root].map(shWord).join(' ')
|
|
40
|
+
const env = target.electron ? 'export ELECTRON_RUN_AS_NODE=1\n' : ''
|
|
41
|
+
return `#!/bin/sh\n# This app's door (acryl-door): reaches only the app running from this folder. Rewritten each time the app starts.\n${env}exec ${words} "$@"\n`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function windowsShim(root: string, target: ShimTarget): string {
|
|
45
|
+
const words = [target.node, ...target.nodeOptions, target.client, '--dir', root].map(cmdWord).join(' ')
|
|
46
|
+
const env = target.electron ? 'set ELECTRON_RUN_AS_NODE=1\r\n' : ''
|
|
47
|
+
return `@echo off\r\nsetlocal\r\nrem This app's door (acryl-door): reaches only the app running from this folder. Rewritten each time the app starts.\r\n${env}${words} %*\r\n`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeAtomically(file: string, content: string, mode: number): void {
|
|
51
|
+
const temporary = `${file}.${String(process.pid)}.tmp`
|
|
52
|
+
writeFileSync(temporary, content, { mode })
|
|
53
|
+
if (process.platform !== 'win32') chmodSync(temporary, mode)
|
|
54
|
+
renameSync(temporary, file)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Write `.acryl/bin/acryl` and `.acryl/bin/acryl.cmd` for the app at `root`. @returns the folder they are in. */
|
|
58
|
+
export function writeShims(root: string, target: ShimTarget): string {
|
|
59
|
+
const bin = join(ensureStateDir(root), BIN_DIR)
|
|
60
|
+
mkdirSync(bin, { recursive: true, mode: 0o700 })
|
|
61
|
+
if (process.platform !== 'win32') chmodSync(bin, 0o700)
|
|
62
|
+
writeAtomically(join(bin, 'acryl'), posixShim(root, target), 0o700)
|
|
63
|
+
writeAtomically(join(bin, 'acryl.cmd'), windowsShim(root, target), 0o600)
|
|
64
|
+
return bin
|
|
65
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one verb list (spec 049 slice 13). Every face of the door reads it: the operator client (`acryl <verb>`, its `verbs` and `brief`),
|
|
3
|
+
* MCP (`tools/list`) and the built-in chat (the `tools` registry). A verb added here is in all three, or the comparison test fails.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS } from '../page/protocol.ts'
|
|
7
|
+
|
|
8
|
+
/** Every verb, in the order `brief` lists them. */
|
|
9
|
+
export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call'] as const
|
|
10
|
+
export type Verb = (typeof VERBS)[number]
|
|
11
|
+
|
|
12
|
+
/** The JSON Schema of a verb's arguments, as MCP lists it. */
|
|
13
|
+
export interface ArgumentsSchema {
|
|
14
|
+
readonly type: 'object'
|
|
15
|
+
readonly properties: Readonly<Record<string, unknown>>
|
|
16
|
+
readonly required?: readonly string[]
|
|
17
|
+
readonly additionalProperties: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface VerbSpec {
|
|
21
|
+
/** The operator client's form, as `brief` prints it. */
|
|
22
|
+
readonly usage: string
|
|
23
|
+
/** What the verb does, for a model choosing a tool. */
|
|
24
|
+
readonly description: string
|
|
25
|
+
readonly arguments: ArgumentsSchema
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const none: ArgumentsSchema = { type: 'object', properties: {}, additionalProperties: false }
|
|
29
|
+
const ref = { type: 'string', description: 'a ref from the latest look, e.g. 3.12' }
|
|
30
|
+
|
|
31
|
+
export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
|
|
32
|
+
'brief': {
|
|
33
|
+
usage: 'brief: this summary',
|
|
34
|
+
description: 'What this app is, which files to read first, whether its page is open, and the verbs.',
|
|
35
|
+
arguments: none,
|
|
36
|
+
},
|
|
37
|
+
'look': {
|
|
38
|
+
usage: 'look [--cursor <n>] [--max-nodes <n>]: the page as text, with refs',
|
|
39
|
+
description: 'The open page as text: its roles, names, values and visible text, each with a ref to act on. A long page comes in parts (cursor).',
|
|
40
|
+
arguments: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
cursor: { type: 'integer', minimum: 0, description: 'where the previous part ended' },
|
|
44
|
+
maxNodes: { type: 'integer', minimum: 1, maximum: MAX_SNAPSHOT_NODES },
|
|
45
|
+
},
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
'act': {
|
|
50
|
+
usage: 'act click|type|select|press|scroll|wait ...: do one thing on the page (refs from the latest look)',
|
|
51
|
+
description: 'Do one thing on the page: click, type, select, press a key, scroll, or wait for text or a control. Look again before the next act.',
|
|
52
|
+
arguments: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
op: { type: 'string', enum: ['click', 'type', 'select', 'press', 'scroll', 'wait'] },
|
|
56
|
+
ref,
|
|
57
|
+
text: { type: 'string', description: 'type: what to type; wait: the text to wait for' },
|
|
58
|
+
submit: { type: 'boolean', description: 'type: press Enter after' },
|
|
59
|
+
clear: { type: 'boolean', description: 'type: false keeps what the field holds' },
|
|
60
|
+
option: { type: 'string', description: 'select: the option to choose' },
|
|
61
|
+
key: { type: 'string', description: 'press: a character or Enter, Escape, Tab, ArrowDown, ...' },
|
|
62
|
+
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
|
|
63
|
+
amount: { type: 'number', minimum: 1, maximum: 10_000, description: 'scroll: pixels' },
|
|
64
|
+
role: { type: 'string', description: 'wait: the role to wait for' },
|
|
65
|
+
name: { type: 'string', description: 'wait: its name' },
|
|
66
|
+
gone: { type: 'boolean', description: 'wait: until it is gone instead' },
|
|
67
|
+
timeoutMs: { type: 'integer', minimum: 0, maximum: MAX_WAIT_MS },
|
|
68
|
+
},
|
|
69
|
+
required: ['op'],
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
'plugin-list': {
|
|
74
|
+
usage: 'plugin list: the plugins installed while the app runs',
|
|
75
|
+
description: 'The plugins installed into the running app.',
|
|
76
|
+
arguments: none,
|
|
77
|
+
},
|
|
78
|
+
'plugin-add': {
|
|
79
|
+
usage: 'plugin add <folder>: check, install and start a plugin folder, live',
|
|
80
|
+
description: 'Check, install and start a plugin folder inside the app, without a restart.',
|
|
81
|
+
arguments: { type: 'object', properties: { folder: { type: 'string', description: 'a folder inside the app, e.g. plugins/pomodoro' } }, required: ['folder'], additionalProperties: false },
|
|
82
|
+
},
|
|
83
|
+
'plugin-remove': {
|
|
84
|
+
usage: 'plugin remove <name>: stop and remove an installed plugin',
|
|
85
|
+
description: 'Stop and remove an installed plugin.',
|
|
86
|
+
arguments: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'], additionalProperties: false },
|
|
87
|
+
},
|
|
88
|
+
'plugin-reload': {
|
|
89
|
+
usage: 'plugin reload: bring every installed plugin in line with its folder',
|
|
90
|
+
description: 'Bring every installed plugin in line with its folder (after editing one).',
|
|
91
|
+
arguments: none,
|
|
92
|
+
},
|
|
93
|
+
'tool-list': {
|
|
94
|
+
usage: 'tool list: the app tools the door offers',
|
|
95
|
+
description: 'The app\'s own tools that the door offers.',
|
|
96
|
+
arguments: none,
|
|
97
|
+
},
|
|
98
|
+
'tool-call': {
|
|
99
|
+
usage: 'tool call --tool <name> [--args <json>]: run one of them',
|
|
100
|
+
description: 'Run one of the app tools that tool-list names.',
|
|
101
|
+
arguments: { type: 'object', properties: { tool: { type: 'string' }, args: { description: 'the tool\'s input' } }, required: ['tool'], additionalProperties: false },
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A verb's name in the `tools` registry the built-in chat reads: prefixed, so it never meets an app tool's name. */
|
|
106
|
+
export const chatToolName = (verb: Verb): string => `door_${verb.replaceAll('-', '_')}`
|
|
107
|
+
|
|
108
|
+
/** True for a name the door itself registers in the `tools` registry. */
|
|
109
|
+
export const isDoorToolName = (name: string): boolean => VERBS.some(verb => chatToolName(verb) === name)
|