@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.
Files changed (108) hide show
  1. package/README.md +10 -0
  2. package/package.json +65 -0
  3. package/src/apps/monogram.ts +15 -0
  4. package/src/apps/registry.ts +238 -0
  5. package/src/apps/startup-seed.ts +95 -0
  6. package/src/bash/client.ts +21 -0
  7. package/src/bash/codes.ts +21 -0
  8. package/src/compile/allowlist.ts +89 -0
  9. package/src/compile/asset-path.ts +48 -0
  10. package/src/compile/autogen.ts +71 -0
  11. package/src/compile/build-vendor-cli.ts +8 -0
  12. package/src/compile/build-vendor.ts +160 -0
  13. package/src/compile/bundle-ui.ts +137 -0
  14. package/src/compile/codes.ts +22 -0
  15. package/src/compile/failure.ts +38 -0
  16. package/src/compile/imports.ts +43 -0
  17. package/src/compile/leave.ts +47 -0
  18. package/src/compile/load-backend.ts +95 -0
  19. package/src/compile/review.ts +342 -0
  20. package/src/compile/runner.ts +205 -0
  21. package/src/compile/sheet.ts +174 -0
  22. package/src/compile/wrapper.ts +297 -0
  23. package/src/credentials/codes.ts +26 -0
  24. package/src/credentials/create.ts +69 -0
  25. package/src/credentials/file.ts +113 -0
  26. package/src/credentials/provider.ts +66 -0
  27. package/src/events/app-events.ts +214 -0
  28. package/src/events/error-ring.ts +109 -0
  29. package/src/events/host-events.ts +39 -0
  30. package/src/events/view-bridge.ts +402 -0
  31. package/src/events/view-eval.ts +213 -0
  32. package/src/files/codes.ts +28 -0
  33. package/src/files/skip.ts +23 -0
  34. package/src/files/tools.ts +215 -0
  35. package/src/history/codes.ts +22 -0
  36. package/src/history/store.ts +287 -0
  37. package/src/host/activity.ts +110 -0
  38. package/src/host/author-mcp.ts +248 -0
  39. package/src/host/author-skill.ts +196 -0
  40. package/src/host/codes.ts +21 -0
  41. package/src/host/config.ts +239 -0
  42. package/src/host/heat.ts +35 -0
  43. package/src/host/layout.ts +81 -0
  44. package/src/host/log.ts +142 -0
  45. package/src/host/mcp-editor.ts +205 -0
  46. package/src/host/mcp-import.ts +141 -0
  47. package/src/host/mcp.ts +109 -0
  48. package/src/host/port.ts +83 -0
  49. package/src/host/session.ts +358 -0
  50. package/src/host/workbench.ts +38 -0
  51. package/src/http/app.ts +46 -0
  52. package/src/http/asset.ts +49 -0
  53. package/src/http/author.ts +157 -0
  54. package/src/http/bind.ts +217 -0
  55. package/src/http/client.ts +175 -0
  56. package/src/http/codes.ts +29 -0
  57. package/src/http/env.ts +3 -0
  58. package/src/http/guard.ts +20 -0
  59. package/src/http/iframe.ts +212 -0
  60. package/src/http/layout.ts +63 -0
  61. package/src/http/owner.ts +380 -0
  62. package/src/http/ports.ts +114 -0
  63. package/src/http/reply.ts +53 -0
  64. package/src/http/route-codes.ts +19 -0
  65. package/src/http/updates.ts +258 -0
  66. package/src/index.ts +201 -0
  67. package/src/install/codes.ts +21 -0
  68. package/src/install/install.ts +230 -0
  69. package/src/install/layout.ts +16 -0
  70. package/src/kernel/bind-brain.ts +117 -0
  71. package/src/kernel/call.ts +134 -0
  72. package/src/kernel/codes.ts +21 -0
  73. package/src/kernel/index.ts +13 -0
  74. package/src/kernel/model-policy.ts +83 -0
  75. package/src/kernel/model-stream.ts +86 -0
  76. package/src/kernel/present.ts +4 -0
  77. package/src/kernel/working-directory.ts +35 -0
  78. package/src/metrics/codes.ts +22 -0
  79. package/src/metrics/read.ts +48 -0
  80. package/src/owner/read.ts +85 -0
  81. package/src/pwsh/client.ts +47 -0
  82. package/src/pwsh/codes.ts +21 -0
  83. package/src/shell/command.ts +172 -0
  84. package/src/storage/codes.ts +33 -0
  85. package/src/storage/layout.ts +44 -0
  86. package/src/storage/open.ts +461 -0
  87. package/src/storage/schema.ts +176 -0
  88. package/src/storage/sql.ts +40 -0
  89. package/src/theme/codes.ts +20 -0
  90. package/src/theme/layout.ts +42 -0
  91. package/src/theme/paint.ts +108 -0
  92. package/src/theme/parse.ts +58 -0
  93. package/src/theme/pin.ts +197 -0
  94. package/src/theme/tokens.ts +30 -0
  95. package/src/tools/author.ts +970 -0
  96. package/src/tools/codes.ts +21 -0
  97. package/src/tools/diagnostics.ts +53 -0
  98. package/src/tools/http.ts +48 -0
  99. package/src/tools/mcp-list.ts +90 -0
  100. package/src/tools/schemas.ts +185 -0
  101. package/themes/theme-default.css +4 -0
  102. package/themes/theme-forest.css +4 -0
  103. package/themes/theme-hokage.css +4 -0
  104. package/themes/theme-matcha.css +4 -0
  105. package/themes/theme-slate.css +4 -0
  106. package/themes/theme-tokyo.css +4 -0
  107. package/themes/theme-yellow.css +4 -0
  108. 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
+ }