@mohou/host 1.0.20
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/README.md +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- package/themes/theme-zoro.css +4 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import { InstallError } from './codes.ts'
|
|
5
|
+
import { installLayout, packageLock, packageManifest } from './layout.ts'
|
|
6
|
+
|
|
7
|
+
/** Names an app must not install. The message names the replacement. */
|
|
8
|
+
const DENIED: Record<string, string> = {
|
|
9
|
+
react: 'use the UI kit',
|
|
10
|
+
'react-dom': 'use the UI kit',
|
|
11
|
+
lodash: 'use the platform lodash import',
|
|
12
|
+
'lodash-es': 'use the platform lodash import',
|
|
13
|
+
axios: 'use ctx.http',
|
|
14
|
+
typescript: 'typescript is not an app dependency',
|
|
15
|
+
motion: 'use the platform motion import',
|
|
16
|
+
'framer-motion': 'use the platform motion import',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Platform scope. These packages are already provided. */
|
|
20
|
+
const PLATFORM_SCOPE = '@mohou/'
|
|
21
|
+
|
|
22
|
+
const NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/
|
|
23
|
+
const VERSION = /^[0-9A-Za-z.^~=*-]+$/
|
|
24
|
+
|
|
25
|
+
/** Install timeout when the caller omits one. Host policy, not a locked number. */
|
|
26
|
+
export const DEFAULT_INSTALL_TIMEOUT_MS = 120_000
|
|
27
|
+
|
|
28
|
+
export interface InstallRequest {
|
|
29
|
+
packages: Array<{ name: string; version?: string }>
|
|
30
|
+
remove: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InstallResult {
|
|
34
|
+
ok: boolean
|
|
35
|
+
packages: Record<string, string>
|
|
36
|
+
lockfile: string | null
|
|
37
|
+
code?: 'install-denied' | 'install-failed'
|
|
38
|
+
message?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Runs `npm install` in the app directory. Tests replace this. */
|
|
42
|
+
export type NpmRun = (appDir: string, signal: AbortSignal) => Promise<{ exitCode: number; stderr: string }>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read or change one app's dependencies. This is the only writer of `package.json`.
|
|
46
|
+
* A failed install restores the previous manifest and lockfile.
|
|
47
|
+
* @param appDir - absolute app directory
|
|
48
|
+
* @param request - admitted add and remove lists; both empty means read
|
|
49
|
+
* @param options - timeout and the npm runner
|
|
50
|
+
*/
|
|
51
|
+
export async function installApp(
|
|
52
|
+
appDir: string,
|
|
53
|
+
request: InstallRequest,
|
|
54
|
+
options: { timeoutMs?: number; run?: NpmRun } = {},
|
|
55
|
+
): Promise<InstallResult> {
|
|
56
|
+
const current = await readManifest(appDir)
|
|
57
|
+
if (request.packages.length === 0 && request.remove.length === 0) {
|
|
58
|
+
return { ok: true, packages: current.packages, lockfile: current.lockfile }
|
|
59
|
+
}
|
|
60
|
+
const denied = deniedSpec(request)
|
|
61
|
+
if (denied !== undefined) return { ok: false, packages: current.packages, lockfile: current.lockfile, ...denied }
|
|
62
|
+
const next = applyRequest(current.packages, request)
|
|
63
|
+
const previousJson = current.raw
|
|
64
|
+
const previousLock = current.lockRaw
|
|
65
|
+
await writeManifest(appDir, next)
|
|
66
|
+
const controller = new AbortController()
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
controller.abort()
|
|
69
|
+
}, options.timeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS)
|
|
70
|
+
try {
|
|
71
|
+
const run = options.run ?? runNpm
|
|
72
|
+
const result = await run(appDir, controller.signal)
|
|
73
|
+
if (result.exitCode !== 0) {
|
|
74
|
+
await restore(appDir, previousJson, previousLock)
|
|
75
|
+
return failed(current, 'install-failed', result.stderr || 'npm install failed')
|
|
76
|
+
}
|
|
77
|
+
const pinned = await pinResolved(appDir, next)
|
|
78
|
+
await writeManifest(appDir, pinned)
|
|
79
|
+
return { ok: true, packages: pinned, lockfile: installLayout.lockfile }
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await restore(appDir, previousJson, previousLock)
|
|
82
|
+
const message = controller.signal.aborted
|
|
83
|
+
? 'npm install timed out'
|
|
84
|
+
: error instanceof Error ? error.message : 'npm install failed'
|
|
85
|
+
return failed(current, 'install-failed', message)
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Spawn npm. Package names stay in `package.json`, not on the command line. */
|
|
92
|
+
export function runNpm(appDir: string, signal: AbortSignal): Promise<{ exitCode: number; stderr: string }> {
|
|
93
|
+
const bin = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawn(bin, ['install', '--ignore-scripts', '--omit=dev'], {
|
|
96
|
+
cwd: appDir,
|
|
97
|
+
signal,
|
|
98
|
+
windowsHide: true,
|
|
99
|
+
shell: process.platform === 'win32',
|
|
100
|
+
})
|
|
101
|
+
let stderr = ''
|
|
102
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
103
|
+
stderr += chunk.toString('utf8')
|
|
104
|
+
})
|
|
105
|
+
child.once('error', reject)
|
|
106
|
+
child.once('close', (code) => {
|
|
107
|
+
resolve({ exitCode: code ?? 1, stderr })
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function deniedSpec(request: InstallRequest): { code: 'install-denied'; message: string } | undefined {
|
|
113
|
+
for (const item of request.packages) {
|
|
114
|
+
const reason = denyName(item.name) ?? denyVersion(item.version)
|
|
115
|
+
if (reason !== undefined) return { code: 'install-denied', message: reason }
|
|
116
|
+
}
|
|
117
|
+
for (const name of request.remove) {
|
|
118
|
+
const reason = denyName(name)
|
|
119
|
+
if (reason !== undefined) return { code: 'install-denied', message: reason }
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function denyName(name: string): string | undefined {
|
|
125
|
+
if (!NAME.test(name)) return `package name is not a plain npm name: ${name}`
|
|
126
|
+
if (name.startsWith(PLATFORM_SCOPE)) return `use the platform import instead of installing ${name}`
|
|
127
|
+
const replacement = DENIED[name]
|
|
128
|
+
return replacement === undefined ? undefined : `${name} is denied: ${replacement}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function denyVersion(version: string | undefined): string | undefined {
|
|
132
|
+
if (version === undefined) return undefined
|
|
133
|
+
if (!VERSION.test(version)) return `package version is not a plain range: ${version}`
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function applyRequest(current: Record<string, string>, request: InstallRequest): Record<string, string> {
|
|
138
|
+
const removed = new Set(request.remove)
|
|
139
|
+
const next: Record<string, string> = {}
|
|
140
|
+
for (const [name, version] of Object.entries(current)) {
|
|
141
|
+
if (!removed.has(name)) next[name] = version
|
|
142
|
+
}
|
|
143
|
+
for (const item of request.packages) next[item.name] = item.version ?? '*'
|
|
144
|
+
return next
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readManifest(appDir: string): Promise<{
|
|
148
|
+
packages: Record<string, string>
|
|
149
|
+
lockfile: string | null
|
|
150
|
+
raw?: string
|
|
151
|
+
lockRaw?: string
|
|
152
|
+
}> {
|
|
153
|
+
const raw = await readFile(packageManifest(appDir), 'utf8').catch(() => undefined)
|
|
154
|
+
const lockRaw = await readFile(packageLock(appDir), 'utf8').catch(() => undefined)
|
|
155
|
+
if (raw === undefined) {
|
|
156
|
+
return {
|
|
157
|
+
packages: {},
|
|
158
|
+
lockfile: lockRaw === undefined ? null : installLayout.lockfile,
|
|
159
|
+
...lockRaw === undefined ? {} : { lockRaw },
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
let parsed: unknown
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(raw) as unknown
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new InstallError('install-failed', 'package.json is not JSON', { cause: error })
|
|
167
|
+
}
|
|
168
|
+
const dependencies = isRecord(parsed) ? parsed.dependencies : undefined
|
|
169
|
+
const packages: Record<string, string> = {}
|
|
170
|
+
if (isRecord(dependencies)) {
|
|
171
|
+
for (const [name, version] of Object.entries(dependencies)) {
|
|
172
|
+
if (typeof version === 'string') packages[name] = version
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
packages,
|
|
177
|
+
lockfile: lockRaw === undefined ? null : installLayout.lockfile,
|
|
178
|
+
raw,
|
|
179
|
+
...lockRaw === undefined ? {} : { lockRaw },
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function writeManifest(appDir: string, packages: Record<string, string>): Promise<void> {
|
|
184
|
+
const body = {
|
|
185
|
+
private: true,
|
|
186
|
+
dependencies: packages,
|
|
187
|
+
}
|
|
188
|
+
await writeFile(packageManifest(appDir), `${JSON.stringify(body, null, 2)}\n`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function restore(appDir: string, raw: string | undefined, lockRaw: string | undefined): Promise<void> {
|
|
192
|
+
const manifest = packageManifest(appDir)
|
|
193
|
+
const lock = packageLock(appDir)
|
|
194
|
+
if (raw === undefined) await rm(manifest, { force: true })
|
|
195
|
+
else await writeFile(manifest, raw)
|
|
196
|
+
if (lockRaw === undefined) await rm(lock, { force: true })
|
|
197
|
+
else await writeFile(lock, lockRaw)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function pinResolved(appDir: string, requested: Record<string, string>): Promise<Record<string, string>> {
|
|
201
|
+
const text = await readFile(packageLock(appDir), 'utf8').catch(() => undefined)
|
|
202
|
+
if (text === undefined) return requested
|
|
203
|
+
let parsed: unknown
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(text) as unknown
|
|
206
|
+
} catch {
|
|
207
|
+
return requested
|
|
208
|
+
}
|
|
209
|
+
const pinned = { ...requested }
|
|
210
|
+
if (!isRecord(parsed)) return pinned
|
|
211
|
+
const packages = isRecord(parsed.packages) ? parsed.packages : {}
|
|
212
|
+
for (const name of Object.keys(pinned)) {
|
|
213
|
+
const entry = packages[`${installLayout.modules}/${name}`]
|
|
214
|
+
const version = isRecord(entry) && typeof entry.version === 'string' ? entry.version : undefined
|
|
215
|
+
if (version !== undefined) pinned[name] = version
|
|
216
|
+
}
|
|
217
|
+
return pinned
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function failed(
|
|
221
|
+
current: { packages: Record<string, string>; lockfile: string | null },
|
|
222
|
+
code: 'install-failed',
|
|
223
|
+
message: string,
|
|
224
|
+
): InstallResult {
|
|
225
|
+
return { ok: false, packages: current.packages, lockfile: current.lockfile, code, message }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
229
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
230
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
/** npm files this host writes. Spell them here, not at each read. */
|
|
4
|
+
export const installLayout = {
|
|
5
|
+
manifest: 'package.json',
|
|
6
|
+
lockfile: 'package-lock.json',
|
|
7
|
+
modules: 'node_modules',
|
|
8
|
+
} as const
|
|
9
|
+
|
|
10
|
+
export function packageManifest(appDir: string): string {
|
|
11
|
+
return path.join(appDir, installLayout.manifest)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function packageLock(appDir: string): string {
|
|
15
|
+
return path.join(appDir, installLayout.lockfile)
|
|
16
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { AppAgentEvent, AppLlmEvent } from '@mohou/contract'
|
|
2
|
+
import { ProviderError, assertKnownModel, type RuntimeAgentOptions, type RuntimeLlmOptions, type RuntimeProvider, type RuntimeProviderConfig } from '@mohou/runtime-provider'
|
|
3
|
+
|
|
4
|
+
import { HostError } from './codes.ts'
|
|
5
|
+
import { admitModelBudget, callWithRetry, stripSchemaFence, type ModelPolicy, DEFAULT_MODEL_POLICY } from './model-policy.ts'
|
|
6
|
+
import { present } from './present.ts'
|
|
7
|
+
import { resolveWorkingDirectory } from './working-directory.ts'
|
|
8
|
+
|
|
9
|
+
function choice(options: { provider?: string; model?: string } | undefined): { provider?: string; model?: string } {
|
|
10
|
+
return {
|
|
11
|
+
...present('provider', options?.provider),
|
|
12
|
+
...present('model', options?.model),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What Host needs to route model calls. Push is the app's `ctx.push`. */
|
|
17
|
+
export interface BrainBinding {
|
|
18
|
+
readonly provider: RuntimeProvider
|
|
19
|
+
readonly config?: RuntimeProviderConfig
|
|
20
|
+
readonly appDir: string
|
|
21
|
+
readonly processDirectory: string
|
|
22
|
+
readonly createTemp: () => string
|
|
23
|
+
readonly push: (name: string, params: unknown) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** App-facing model calls after the brain has started. */
|
|
27
|
+
export interface BoundBrain {
|
|
28
|
+
llm(prompt: string, options?: RuntimeLlmOptions): Promise<string>
|
|
29
|
+
agent(goal: string, options?: RuntimeAgentOptions): Promise<string>
|
|
30
|
+
stop(): Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Configure and start one injected brain, then route `llm` and `agent`.
|
|
35
|
+
* Host does not choose the vendor. `start` throwing fails the caller.
|
|
36
|
+
* @param binding - the provider Shell selected, plus directories and push
|
|
37
|
+
*/
|
|
38
|
+
export async function bindBrain(binding: BrainBinding, policy: ModelPolicy = DEFAULT_MODEL_POLICY): Promise<BoundBrain> {
|
|
39
|
+
binding.provider.configure?.(binding.config ?? {})
|
|
40
|
+
await binding.provider.start()
|
|
41
|
+
return {
|
|
42
|
+
async llm(prompt, options) {
|
|
43
|
+
const budget = admitModelBudget(options, policy)
|
|
44
|
+
await assertKnownModel(binding.provider, choice(options))
|
|
45
|
+
return callWithRetry(budget.retryTimes, options?.signal, async () => {
|
|
46
|
+
const text = await binding.provider.llm(prompt, llmCall(options, budget.maxTokens))
|
|
47
|
+
return options?.schema === undefined ? text : stripSchemaFence(text)
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
async agent(goal, options) {
|
|
51
|
+
try {
|
|
52
|
+
const budget = admitModelBudget(options, policy)
|
|
53
|
+
await assertKnownModel(binding.provider, choice(options))
|
|
54
|
+
const cwd = resolveWorkingDirectory({
|
|
55
|
+
...present('cwdType', options?.cwdType),
|
|
56
|
+
...present('cwd', options?.cwd),
|
|
57
|
+
appDir: binding.appDir,
|
|
58
|
+
processDirectory: binding.processDirectory,
|
|
59
|
+
createTemp: binding.createTemp,
|
|
60
|
+
})
|
|
61
|
+
const controller = new AbortController()
|
|
62
|
+
if (options?.signal?.aborted) controller.abort()
|
|
63
|
+
else options?.signal?.addEventListener('abort', () => {
|
|
64
|
+
controller.abort()
|
|
65
|
+
}, { once: true })
|
|
66
|
+
let completedTurns = 0
|
|
67
|
+
const onEvent = (event: AppAgentEvent): void => {
|
|
68
|
+
try {
|
|
69
|
+
options?.onEvent?.(event)
|
|
70
|
+
} catch {
|
|
71
|
+
// A throwing observer does not abort the run.
|
|
72
|
+
}
|
|
73
|
+
if (event.type === 'turn' && event.phase === 'end') {
|
|
74
|
+
completedTurns += 1
|
|
75
|
+
if (completedTurns >= budget.maxIterations) controller.abort()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return await callWithRetry(budget.retryTimes, options?.signal, () => binding.provider.agent(goal, {
|
|
79
|
+
...options,
|
|
80
|
+
cwd,
|
|
81
|
+
cwdType: 'custom',
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
onEvent,
|
|
84
|
+
}))
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw namedAgent(error)
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
// The host owns the shared provider. A finished app call must not clear it.
|
|
90
|
+
stop: () => Promise.resolve(),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function llmCall(options: RuntimeLlmOptions | undefined, maxTokens: number): RuntimeLlmOptions {
|
|
95
|
+
const budgeted = { ...options, maxTokens: options?.maxTokens ?? maxTokens }
|
|
96
|
+
if (options?.stream !== true) {
|
|
97
|
+
const { onEvent: _ignored, ...quiet } = budgeted
|
|
98
|
+
return quiet
|
|
99
|
+
}
|
|
100
|
+
const onEvent = (event: AppLlmEvent): void => {
|
|
101
|
+
try {
|
|
102
|
+
options.onEvent?.(event)
|
|
103
|
+
} catch {
|
|
104
|
+
// A throwing observer does not abort the completion.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { ...budgeted, onEvent }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function namedAgent(error: unknown): Error {
|
|
111
|
+
if (error instanceof ProviderError) {
|
|
112
|
+
return new ProviderError(error.code, `agent: ${error.message}`, { cause: error })
|
|
113
|
+
}
|
|
114
|
+
if (error instanceof HostError) return new HostError(error.code, `agent: ${error.message}`)
|
|
115
|
+
const message = error instanceof Error ? error.message : 'agent failed'
|
|
116
|
+
return new ProviderError('provider-unhealthy', `agent: ${message}`, { cause: error })
|
|
117
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppAgentEvent,
|
|
3
|
+
AppApiMethod,
|
|
4
|
+
AppConfig,
|
|
5
|
+
AppContext,
|
|
6
|
+
AppCredentials,
|
|
7
|
+
AppHttpRequest,
|
|
8
|
+
AppHttpResponse,
|
|
9
|
+
AppId,
|
|
10
|
+
AppLlmEvent,
|
|
11
|
+
AppStorage,
|
|
12
|
+
AppSystemMetrics,
|
|
13
|
+
AppWorkbench,
|
|
14
|
+
} from '@mohou/contract'
|
|
15
|
+
|
|
16
|
+
import type { CredentialProvider } from '../credentials/provider.ts'
|
|
17
|
+
import type { BoundBrain } from './bind-brain.ts'
|
|
18
|
+
import { HostError } from './codes.ts'
|
|
19
|
+
import { modelStream } from './model-stream.ts'
|
|
20
|
+
import { present } from './present.ts'
|
|
21
|
+
|
|
22
|
+
/** Ports already constructed outside the kernel. The call loop only binds them. */
|
|
23
|
+
export interface CallCapabilities<State extends object = Record<string, never>> {
|
|
24
|
+
readonly appId: AppId
|
|
25
|
+
readonly appDir: string
|
|
26
|
+
readonly state: State
|
|
27
|
+
readonly storage: AppStorage
|
|
28
|
+
readonly credentials: CredentialProvider
|
|
29
|
+
readonly config: AppConfig
|
|
30
|
+
readonly log: (...args: unknown[]) => void
|
|
31
|
+
readonly push: (name: string, params?: unknown) => void
|
|
32
|
+
readonly http: (url: string | AppHttpRequest, opts?: Omit<AppHttpRequest, 'url'>) => Promise<AppHttpResponse>
|
|
33
|
+
readonly bash: (command: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>
|
|
34
|
+
readonly pwsh: (command: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>
|
|
35
|
+
readonly metrics: () => Promise<AppSystemMetrics>
|
|
36
|
+
readonly mcp: (serverId: string, toolName: string, args?: Record<string, unknown>) => Promise<unknown>
|
|
37
|
+
readonly brain: BoundBrain
|
|
38
|
+
readonly workbench?: AppWorkbench
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run one app method inside a context built from injected ports.
|
|
43
|
+
* A method that yields, or that returns a model stream, is read here.
|
|
44
|
+
* `publish` receives each yielded value. A plain `call` omits it.
|
|
45
|
+
* The signal aborts when the call settles. An unknown name emits `unknown-method`.
|
|
46
|
+
*/
|
|
47
|
+
export async function runCall<State extends object>(
|
|
48
|
+
capabilities: CallCapabilities<State>,
|
|
49
|
+
api: Record<string, AppApiMethod<State>>,
|
|
50
|
+
name: string,
|
|
51
|
+
args: unknown,
|
|
52
|
+
publish?: (value: unknown) => Promise<void> | void,
|
|
53
|
+
): Promise<unknown> {
|
|
54
|
+
const method = api[name]
|
|
55
|
+
if (method === undefined) {
|
|
56
|
+
throw new HostError('unknown-method', `unknown api method: ${name}`)
|
|
57
|
+
}
|
|
58
|
+
const controller = new AbortController()
|
|
59
|
+
const ctx: AppContext<State> = contextFor(capabilities, controller.signal)
|
|
60
|
+
try {
|
|
61
|
+
return await readMethod(method(ctx, args), publish)
|
|
62
|
+
} finally {
|
|
63
|
+
controller.abort()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function credentialsFor(provider: CredentialProvider): AppCredentials {
|
|
68
|
+
return {
|
|
69
|
+
get: name => provider.get(name),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contextFor<State extends object>(capabilities: CallCapabilities<State>, signal: AbortSignal): AppContext<State> {
|
|
74
|
+
return {
|
|
75
|
+
appId: capabilities.appId,
|
|
76
|
+
appDir: capabilities.appDir,
|
|
77
|
+
storage: capabilities.storage,
|
|
78
|
+
state: capabilities.state,
|
|
79
|
+
credentials: credentialsFor(capabilities.credentials),
|
|
80
|
+
config: capabilities.config,
|
|
81
|
+
log: capabilities.log,
|
|
82
|
+
signal,
|
|
83
|
+
push: capabilities.push,
|
|
84
|
+
http: capabilities.http,
|
|
85
|
+
bash: capabilities.bash,
|
|
86
|
+
pwsh: capabilities.pwsh,
|
|
87
|
+
system: { metrics: capabilities.metrics },
|
|
88
|
+
llm: ((prompt, options) => {
|
|
89
|
+
if (options?.stream !== true) {
|
|
90
|
+
return capabilities.brain.llm(prompt, { ...options, signal: options?.signal ?? signal })
|
|
91
|
+
}
|
|
92
|
+
return modelStream((emit: (event: AppLlmEvent) => void) => capabilities.brain.llm(prompt, {
|
|
93
|
+
...options,
|
|
94
|
+
signal: options.signal ?? signal,
|
|
95
|
+
onEvent: emit,
|
|
96
|
+
}))
|
|
97
|
+
}) as AppContext<State>['llm'],
|
|
98
|
+
agent: ((goal, options) => {
|
|
99
|
+
const forwarded = {
|
|
100
|
+
...options,
|
|
101
|
+
...present('signal', options?.signal ?? signal),
|
|
102
|
+
}
|
|
103
|
+
if (options?.stream !== true) return capabilities.brain.agent(goal, forwarded)
|
|
104
|
+
return modelStream((emit: (event: AppAgentEvent) => void) => capabilities.brain.agent(goal, {
|
|
105
|
+
...forwarded,
|
|
106
|
+
onEvent: emit,
|
|
107
|
+
}))
|
|
108
|
+
}) as AppContext<State>['agent'],
|
|
109
|
+
mcp: capabilities.mcp,
|
|
110
|
+
...capabilities.workbench === undefined ? {} : { workbench: capabilities.workbench },
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function readMethod(output: unknown, publish?: (value: unknown) => Promise<void> | void): Promise<unknown> {
|
|
115
|
+
const value = isThenable(output) && !isAsyncIterable(output) ? await output : output
|
|
116
|
+
if (!isAsyncIterable(value)) return value
|
|
117
|
+
const iterator = value[Symbol.asyncIterator]()
|
|
118
|
+
let step = await iterator.next()
|
|
119
|
+
while (!step.done) {
|
|
120
|
+
await publish?.(step.value)
|
|
121
|
+
step = await iterator.next()
|
|
122
|
+
}
|
|
123
|
+
if (step.value !== undefined) return step.value
|
|
124
|
+
if (isThenable(value)) return value
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
129
|
+
return typeof value === 'object' && value !== null && Symbol.asyncIterator in value
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
133
|
+
return typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function'
|
|
134
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Codes this host slice emits. */
|
|
2
|
+
|
|
3
|
+
export const hostCodes = ['cwd-invalid', 'unknown-method', 'model-policy'] as const
|
|
4
|
+
|
|
5
|
+
/** A failure emitted while preparing a brain call. */
|
|
6
|
+
export type HostCode = (typeof hostCodes)[number]
|
|
7
|
+
|
|
8
|
+
/** Failure a caller branches on. The message is for a person. */
|
|
9
|
+
export class HostError extends Error {
|
|
10
|
+
readonly code: HostCode
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param code - one of {@link hostCodes}
|
|
14
|
+
* @param message - human text; not the match key
|
|
15
|
+
*/
|
|
16
|
+
constructor(code: HostCode, message: string) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = 'HostError'
|
|
19
|
+
this.code = code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Call loop. @module @mohou/host */
|
|
2
|
+
|
|
3
|
+
export { HostError, hostCodes, type HostCode } from './codes.ts'
|
|
4
|
+
export { resolveWorkingDirectory, type WorkingDirectoryInput } from './working-directory.ts'
|
|
5
|
+
export { bindBrain, type BoundBrain, type BrainBinding } from './bind-brain.ts'
|
|
6
|
+
export {
|
|
7
|
+
DEFAULT_MODEL_POLICY,
|
|
8
|
+
admitModelBudget,
|
|
9
|
+
callWithRetry,
|
|
10
|
+
stripSchemaFence,
|
|
11
|
+
type ModelPolicy,
|
|
12
|
+
} from './model-policy.ts'
|
|
13
|
+
export { runCall, type CallCapabilities } from './call.ts'
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ProviderError } from '@mohou/runtime-provider'
|
|
2
|
+
|
|
3
|
+
import { HostError } from './codes.ts'
|
|
4
|
+
|
|
5
|
+
/** Token and retry bounds. Host policy, not a locked product value. */
|
|
6
|
+
export interface ModelPolicy {
|
|
7
|
+
maxTokens: number
|
|
8
|
+
maxTokensLimit: number
|
|
9
|
+
retryTimes: number
|
|
10
|
+
retryTimesLimit: number
|
|
11
|
+
maxIterations: number
|
|
12
|
+
maxIterationsLimit: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_MODEL_POLICY: ModelPolicy = {
|
|
16
|
+
maxTokens: 2_048,
|
|
17
|
+
maxTokensLimit: 8_192,
|
|
18
|
+
retryTimes: 1,
|
|
19
|
+
retryTimesLimit: 3,
|
|
20
|
+
maxIterations: 100,
|
|
21
|
+
maxIterationsLimit: 100,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Strip one markdown fence when `schema` asked for JSON. The caller still parses the string. */
|
|
25
|
+
export function stripSchemaFence(text: string): string {
|
|
26
|
+
const match = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(text.trim())
|
|
27
|
+
return match?.[1] ?? text
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function admitModelBudget(options: {
|
|
31
|
+
maxTokens?: number
|
|
32
|
+
retryTimes?: number
|
|
33
|
+
maxIterations?: number
|
|
34
|
+
} | undefined, policy: ModelPolicy): {
|
|
35
|
+
maxTokens: number
|
|
36
|
+
retryTimes: number
|
|
37
|
+
maxIterations: number
|
|
38
|
+
} {
|
|
39
|
+
return {
|
|
40
|
+
maxTokens: admitNumber(options?.maxTokens, policy.maxTokens, policy.maxTokensLimit, 'maxTokens'),
|
|
41
|
+
retryTimes: admitNumber(options?.retryTimes, policy.retryTimes, policy.retryTimesLimit, 'retryTimes'),
|
|
42
|
+
maxIterations: admitNumber(options?.maxIterations, policy.maxIterations, policy.maxIterationsLimit, 'maxIterations'),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run a model call up to `times`, counting the first attempt.
|
|
48
|
+
* Cancel is not retried. An empty string is a failed attempt.
|
|
49
|
+
*/
|
|
50
|
+
export async function callWithRetry(
|
|
51
|
+
times: number,
|
|
52
|
+
signal: AbortSignal | undefined,
|
|
53
|
+
run: () => Promise<string>,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
let last: unknown
|
|
56
|
+
for (let attempt = 0; attempt < times; attempt += 1) {
|
|
57
|
+
if (signal?.aborted) throw new ProviderError('cancelled', 'model call was cancelled')
|
|
58
|
+
try {
|
|
59
|
+
const text = await run()
|
|
60
|
+
if (text.length === 0) throw new ProviderError('empty-completion', 'model call returned an empty completion')
|
|
61
|
+
return text
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (isCancelled(error, signal)) {
|
|
64
|
+
throw error instanceof ProviderError ? error : new ProviderError('cancelled', 'model call was cancelled', { cause: error })
|
|
65
|
+
}
|
|
66
|
+
last = error
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw new ProviderError('retry-exhausted', 'model call retries exhausted', { cause: last })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function admitNumber(value: number | undefined, fallback: number, limit: number, field: string): number {
|
|
73
|
+
const chosen = value ?? fallback
|
|
74
|
+
if (!Number.isInteger(chosen) || chosen < 1 || chosen > limit) {
|
|
75
|
+
throw new HostError('model-policy', `${field} is outside the host policy`)
|
|
76
|
+
}
|
|
77
|
+
return chosen
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
81
|
+
if (signal?.aborted) return true
|
|
82
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'cancelled'
|
|
83
|
+
}
|