@goodandready/dsh-moa 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Parses markdown code blocks that specify file paths.
6
+ * Supports patterns like:
7
+ * ```html file="index.html"
8
+ * ```javascript filepath=script.js
9
+ * // File: index.html
10
+ * <!-- File: styles.css -->
11
+ * ### File: index.html
12
+ * ```html
13
+ */
14
+ export function extractFileBlocks(text) {
15
+ if (!text || typeof text !== 'string') return []
16
+
17
+ const files = []
18
+ const seenPaths = new Set()
19
+
20
+ // Pattern 1: fenced blocks with file="..." or path="..." in info string
21
+ // Example: ```html file="index.html" or ```js path=src/main.js
22
+ const fenceRegex = /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi
23
+ let match
24
+ while ((match = fenceRegex.exec(text)) !== null) {
25
+ const rawPath = match[1].trim()
26
+ const content = match[2]
27
+ if (rawPath && !seenPaths.has(rawPath)) {
28
+ seenPaths.add(rawPath)
29
+ files.push({ relativePath: sanitizePath(rawPath), content })
30
+ }
31
+ }
32
+
33
+ // Pattern 2: Explicit headers before code blocks, e.g.
34
+ // **File: `index.html`** or ### File: index.html or `File: index.html` followed by ```
35
+ const headerRegex = /(?:###?\s*File:\s*|File:\s*|#\s*File:\s*)[`"]?([a-zA-Z0-9_.\-\/\\]+)[`"]?\s*[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi
36
+ while ((match = headerRegex.exec(text)) !== null) {
37
+ const rawPath = match[1].trim()
38
+ const content = match[2]
39
+ if (rawPath && !seenPaths.has(rawPath)) {
40
+ seenPaths.add(rawPath)
41
+ files.push({ relativePath: sanitizePath(rawPath), content })
42
+ }
43
+ }
44
+
45
+ // Pattern 3: Comment inside the code block on line 1: // File: foo.js or /* File: foo.css */ or <!-- File: index.html -->
46
+ const commentRegex = /```[\w-]*[\r\n]+(?:\/\/|\/\*|<!--|#)\s*(?:File|filename):\s*([a-zA-Z0-9_.\-\/\\]+)(?:\s*\*\/|\s*-->)?[\r\n]+([\s\S]*?)```/gi
47
+ while ((match = commentRegex.exec(text)) !== null) {
48
+ const rawPath = match[1].trim()
49
+ const content = match[2]
50
+ if (rawPath && !seenPaths.has(rawPath)) {
51
+ seenPaths.add(rawPath)
52
+ files.push({ relativePath: sanitizePath(rawPath), content })
53
+ }
54
+ }
55
+
56
+ // Fallback: If no annotated blocks found, check if text has exactly one prominent standalone code block with common extensions
57
+ if (files.length === 0) {
58
+ const singleBlockRegex = /```(html|css|javascript|js|json|python|py|sh|bash)[\r\n]+([\s\S]*?)```/gi
59
+ const singleMatches = []
60
+ while ((match = singleBlockRegex.exec(text)) !== null) {
61
+ singleMatches.push({ lang: match[1].toLowerCase(), content: match[2] })
62
+ }
63
+ if (singleMatches.length === 1) {
64
+ const extMap = {
65
+ html: 'index.html',
66
+ javascript: 'index.js',
67
+ js: 'index.js',
68
+ css: 'style.css',
69
+ python: 'main.py',
70
+ py: 'main.py',
71
+ json: 'data.json',
72
+ bash: 'run.sh',
73
+ sh: 'run.sh',
74
+ }
75
+ const defaultName = extMap[singleMatches[0].lang] || 'output.txt'
76
+ files.push({ relativePath: defaultName, content: singleMatches[0].content })
77
+ }
78
+ }
79
+
80
+ return files
81
+ }
82
+
83
+ function sanitizePath(filePath) {
84
+ const normalized = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '')
85
+ return normalized.replace(/^[\\\/]+/, '')
86
+ }
87
+
88
+ /**
89
+ * Writes candidate files into an isolated candidate workspace:
90
+ * <baseDir>/.moa/candidate-<index>/
91
+ */
92
+ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = []) {
93
+ if (!baseDir || !Array.isArray(files) || files.length === 0) return []
94
+
95
+ const candidateDir = path.join(baseDir, '.moa', `candidate-${candidateIndex}`)
96
+ await fs.mkdir(candidateDir, { recursive: true })
97
+
98
+ const written = []
99
+ for (const file of files) {
100
+ const fullPath = path.join(candidateDir, file.relativePath)
101
+ await fs.mkdir(path.dirname(fullPath), { recursive: true })
102
+ await fs.writeFile(fullPath, file.content, 'utf8')
103
+ written.push({
104
+ relativePath: file.relativePath,
105
+ fullPath,
106
+ size: Buffer.byteLength(file.content, 'utf8'),
107
+ })
108
+ }
109
+
110
+ return written
111
+ }
112
+
113
+ /**
114
+ * Promotes winning candidate files to baseDir root,
115
+ * then cleans up the temporary .moa folder.
116
+ */
117
+ export async function promoteCandidateWorkspace(baseDir, winningIndex) {
118
+ if (!baseDir || winningIndex === null || winningIndex === undefined) return []
119
+
120
+ const winningDir = path.join(baseDir, '.moa', `candidate-${winningIndex}`)
121
+ const moaRoot = path.join(baseDir, '.moa')
122
+
123
+ try {
124
+ const entries = await getFilesRecursively(winningDir)
125
+ if (entries.length === 0) {
126
+ try { await fs.rm(moaRoot, { recursive: true, force: true }) } catch {}
127
+ return []
128
+ }
129
+
130
+ const promoted = []
131
+ for (const rel of entries) {
132
+ const srcPath = path.join(winningDir, rel)
133
+ const destPath = path.join(baseDir, rel)
134
+
135
+ await fs.mkdir(path.dirname(destPath), { recursive: true })
136
+ await fs.copyFile(srcPath, destPath)
137
+ promoted.push(rel)
138
+ }
139
+
140
+ // Clean up temporary .moa folder
141
+ await fs.rm(moaRoot, { recursive: true, force: true })
142
+
143
+ return promoted
144
+ } catch (err) {
145
+ try { await fs.rm(moaRoot, { recursive: true, force: true }) } catch {}
146
+ return []
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Cleans up any left-over .moa candidate folder.
152
+ */
153
+ export async function cleanMoaWorkspaces(baseDir) {
154
+ if (!baseDir) return
155
+ const moaRoot = path.join(baseDir, '.moa')
156
+ try {
157
+ await fs.rm(moaRoot, { recursive: true, force: true })
158
+ } catch {}
159
+ }
160
+
161
+ async function getFilesRecursively(dir) {
162
+ const results = []
163
+ try {
164
+ const stat = await fs.stat(dir)
165
+ if (!stat.isDirectory()) return []
166
+ } catch {
167
+ return []
168
+ }
169
+
170
+ async function scan(current, relPrefix = '') {
171
+ try {
172
+ const list = await fs.readdir(current, { withFileTypes: true })
173
+ for (const item of list) {
174
+ const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
175
+ const full = path.join(current, item.name)
176
+ if (item.isDirectory()) {
177
+ await scan(full, rel)
178
+ } else if (item.isFile()) {
179
+ results.push(rel)
180
+ }
181
+ }
182
+ } catch {}
183
+ }
184
+ await scan(dir)
185
+ return results
186
+ }
package/lib/index.js ADDED
@@ -0,0 +1,494 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import z from '@deepseek-ai/schemastery'
4
+ import { runMoAPipeline, parseMoACommand, formatMoAResponse, MoaRunnerAdapter } from './moa-runner.js'
5
+
6
+ export const name = '@goodandready/dsh-moa'
7
+ export const inject = ['settings', 'webServer', 'llm', 'credentials', 'sessions', 'agents']
8
+
9
+ export { parseMoACommand }
10
+
11
+ export const ModelSlotSchema = z.object({
12
+ provider: z.string().default(''),
13
+ model: z.string().default(''),
14
+ })
15
+
16
+ export const PresetSchema = z.object({
17
+ name: z.string().default('default'),
18
+ enabled: z.boolean().default(true),
19
+ reference_models: z.array(ModelSlotSchema).default([
20
+ { provider: 'opencode-go', model: 'deepseek-v4-flash' },
21
+ { provider: 'codex', model: 'gpt-5.6-sol' },
22
+ ]),
23
+ aggregator: ModelSlotSchema.default({
24
+ provider: 'antigravity',
25
+ model: 'gemini-3-flash',
26
+ }),
27
+ reference_temperature: z.number().step(0.05).min(0).max(2).default(0.6),
28
+ aggregator_temperature: z.number().step(0.05).min(0).max(2).default(0.4),
29
+ max_tokens: z.number().min(256).max(65536).default(4096),
30
+ })
31
+
32
+ export const Config = z.object({
33
+ enabled: z.boolean().default(true),
34
+ default_preset: z.string().default('default'),
35
+ presets: z.array(PresetSchema).default([
36
+ {
37
+ name: 'default',
38
+ enabled: true,
39
+ reference_models: [
40
+ { provider: 'opencode-go', model: 'deepseek-v4-flash' },
41
+ { provider: 'codex', model: 'gpt-5.6-sol' },
42
+ ],
43
+ aggregator: {
44
+ provider: 'antigravity',
45
+ model: 'gemini-3-flash',
46
+ },
47
+ reference_temperature: 0.6,
48
+ aggregator_temperature: 0.4,
49
+ max_tokens: 4096,
50
+ },
51
+ {
52
+ name: 'deep-reasoning',
53
+ enabled: true,
54
+ reference_models: [
55
+ { provider: 'grok', model: 'grok-4.20-0309-reasoning' },
56
+ { provider: 'codex', model: 'gpt-5.6-terra' },
57
+ ],
58
+ aggregator: {
59
+ provider: 'antigravity',
60
+ model: 'gemini-3-flash',
61
+ },
62
+ reference_temperature: 0.7,
63
+ aggregator_temperature: 0.2,
64
+ max_tokens: 8192,
65
+ },
66
+ ]),
67
+ })
68
+
69
+ const NS = 'dsh-moa'
70
+
71
+ function writeJson(res, code, data) {
72
+ try {
73
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
74
+ res.end(JSON.stringify(data))
75
+ } catch {
76
+ /* socket closed */
77
+ }
78
+ }
79
+
80
+ function readBody(req) {
81
+ return new Promise((resolve, reject) => {
82
+ const chunks = []
83
+ req.on('data', (c) => chunks.push(c))
84
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
85
+ req.on('error', reject)
86
+ })
87
+ }
88
+
89
+
90
+ export function apply(ctx, config) {
91
+ let settingsApi = null
92
+ let currentConfig = structuredClone(config)
93
+
94
+ // Try reading persisted config from ~/.dsh/settings.yaml on load
95
+ try {
96
+ const fs = require('node:fs')
97
+ const yaml = require('yaml')
98
+ const p = path.join(os.homedir(), '.dsh', 'settings.yaml')
99
+ if (fs.existsSync(p)) {
100
+ const parsed = yaml.parse(fs.readFileSync(p, 'utf8'))
101
+ if (parsed && parsed[NS]) {
102
+ currentConfig = Config(parsed[NS])
103
+ }
104
+ }
105
+ } catch {}
106
+
107
+ ctx.inject(['settings'], (sctx) => {
108
+ const scope = sctx.settings.register(NS, Config, { base: currentConfig })
109
+ settingsApi = scope
110
+ const val = scope.get()
111
+ if (val && Object.keys(val).length > 0) {
112
+ currentConfig = val
113
+ }
114
+ sctx.effect(() => () => {
115
+ settingsApi = null
116
+ })
117
+ })
118
+
119
+ const getConfig = () => {
120
+ if (settingsApi) {
121
+ const live = settingsApi.get()
122
+ if (live && live.presets && live.presets.length) return live
123
+ }
124
+ return currentConfig || config
125
+ }
126
+
127
+ // Canonical collector for ctx.llm.stream chunk streams
128
+ const collectStreamText = async (iterable) => {
129
+ let out = ''
130
+ let sawDelta = false
131
+ for await (const chunk of iterable) {
132
+ if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') {
133
+ out += chunk.text
134
+ sawDelta = true
135
+ } else if (
136
+ !sawDelta && chunk && chunk.type === 'block-end' &&
137
+ chunk.block && chunk.block.type === 'text' && typeof chunk.block.text === 'string'
138
+ ) {
139
+ out += chunk.block.text
140
+ } else if (chunk && chunk.type === 'finish' && chunk.reason && chunk.reason.kind === 'error') {
141
+ throw new Error(chunk.reason.failure?.message || 'LLM stream failed')
142
+ }
143
+ }
144
+ return out.trim()
145
+ }
146
+
147
+ // Adapter to ctx.llm.stream
148
+ const callLlm = async (params) => {
149
+ if (!ctx.llm || typeof ctx.llm.stream !== 'function') {
150
+ throw new Error('ctx.llm.stream service is not available')
151
+ }
152
+
153
+ const { provider, model, messages, temperature, maxTokens, signal } = params
154
+
155
+ // Convert string/simple role messages to format expected by ctx.llm.stream
156
+ // Each message must have role, content (array of blocks or string), and source
157
+ const formattedMessages = (messages || []).map((m) => {
158
+ let content = m.content
159
+ if (typeof content === 'string') {
160
+ content = [{ type: 'text', text: content }]
161
+ } else if (Array.isArray(content)) {
162
+ content = content.map((b) => (typeof b === 'string' ? { type: 'text', text: b } : b))
163
+ } else {
164
+ content = [{ type: 'text', text: String(content || '') }]
165
+ }
166
+
167
+ const role = m.role || 'user'
168
+ const source = m.source || (role === 'assistant' ? { kind: 'model', provider, model } : { kind: 'user' })
169
+
170
+ return {
171
+ role,
172
+ content,
173
+ source,
174
+ }
175
+ })
176
+
177
+ // Provider-specific parameter cleansing (Codex / reasoning models reject temperature, max_output_tokens, etc.)
178
+ const isCodexOrReasoning = provider === 'codex' || String(model).includes('sol') || String(model).includes('reasoner')
179
+ const streamOpts = {
180
+ provider,
181
+ model,
182
+ messages: formattedMessages,
183
+ ...(signal ? { signal } : {}),
184
+ }
185
+
186
+ if (!isCodexOrReasoning) {
187
+ if (temperature !== undefined) streamOpts.temperature = temperature
188
+ if (maxTokens !== undefined) streamOpts.maxTokens = maxTokens
189
+ }
190
+
191
+ const chunks = ctx.llm.stream(streamOpts)
192
+
193
+ return await collectStreamText(chunks)
194
+ }
195
+
196
+ // HTTP endpoints
197
+ ctx.effect(() => {
198
+ return ctx.webServer.register({
199
+ kind: 'exact',
200
+ path: '/dsh-moa/status',
201
+ handler: (_req, res) => {
202
+ const cfg = getConfig()
203
+ writeJson(res, 200, {
204
+ ok: true,
205
+ enabled: cfg.enabled !== false,
206
+ defaultPreset: cfg.default_preset || 'default',
207
+ hasCtxLlm: Boolean(ctx.llm),
208
+ llmKeys: ctx.llm ? Object.keys(ctx.llm) : [],
209
+ llmProto: ctx.llm ? Object.getOwnPropertyNames(Object.getPrototypeOf(ctx.llm)) : [],
210
+ presetsCount: (cfg.presets || []).length,
211
+ })
212
+ },
213
+ })
214
+ }, 'dsh-moa: status route')
215
+
216
+ ctx.effect(() => {
217
+ return ctx.webServer.register({
218
+ kind: 'exact',
219
+ path: '/dsh-moa/models',
220
+ handler: async (_req, res) => {
221
+ const result = []
222
+ const seen = new Set()
223
+
224
+ const add = (prov, mod, label) => {
225
+ if (!prov || !mod) return
226
+ const p = String(prov).trim()
227
+ const m = String(mod).trim()
228
+ if (!p || !m) return
229
+ const key = `${p}:${m}`
230
+ if (seen.has(key)) return
231
+ seen.add(key)
232
+ result.push({ provider: p, model: m, label: label || key })
233
+ }
234
+
235
+ // ONLY query REAL models from runtime adapters & settings. NO hardcoded fallbacks!
236
+ if (ctx.llm) {
237
+ try {
238
+ if (typeof ctx.llm.listProviders === 'function') {
239
+ const provList = ctx.llm.listProviders() || []
240
+ for (const p of provList) {
241
+ const pId = typeof p === 'string' ? p : (p.id || p.provider || p.name)
242
+ if (!pId) continue
243
+ if (typeof ctx.llm.listModels === 'function') {
244
+ try {
245
+ const mList = await ctx.llm.listModels(pId)
246
+ for (const m of (mList || [])) {
247
+ const mId = typeof m === 'string' ? m : (m.id || m.model || m.name)
248
+ const mLabel = m.displayName || m.name || mId
249
+ if (mId) add(pId, mId, `${pId}: ${mLabel}`)
250
+ }
251
+ } catch {}
252
+ }
253
+ for (const m of (p.models || [])) {
254
+ const mId = typeof m === 'string' ? m : (m.id || m.model || m.name)
255
+ if (mId) add(pId, mId)
256
+ }
257
+ }
258
+ }
259
+ } catch (e) {
260
+ console.warn('[dsh-moa] ctx.llm.listProviders error:', e)
261
+ }
262
+
263
+ try {
264
+ if (typeof ctx.llm.listConfigurableProviders === 'function') {
265
+ const cProvs = ctx.llm.listConfigurableProviders() || []
266
+ for (const cp of cProvs) {
267
+ const cpId = cp.provider || cp.id
268
+ if (cpId && typeof ctx.llm.listModels === 'function') {
269
+ try {
270
+ const mList = await ctx.llm.listModels(cpId)
271
+ for (const m of (mList || [])) {
272
+ const mId = typeof m === 'string' ? m : (m.id || m.model || m.name)
273
+ if (mId) add(cpId, mId)
274
+ }
275
+ } catch {}
276
+ }
277
+ }
278
+ }
279
+ } catch (e) {
280
+ console.warn('[dsh-moa] ctx.llm.listConfigurableProviders error:', e)
281
+ }
282
+ }
283
+
284
+ // Query settings for configured providers
285
+ try {
286
+ const s = ctx.get ? ctx.get('settings') : ctx.settings
287
+ const getSetting = (k) => {
288
+ try { return s?.get?.(k) } catch { return undefined }
289
+ }
290
+
291
+ const def = getSetting('agent-default-model')
292
+ if (def?.provider && def?.model) {
293
+ add(def.provider, def.model)
294
+ }
295
+
296
+ const piAi = getSetting('llm-pi-ai')
297
+ for (const [pName, pData] of Object.entries(piAi?.providers || {})) {
298
+ for (const m of (pData?.models || [])) {
299
+ const mId = typeof m === 'string' ? m : (m?.id || m?.model)
300
+ if (mId) add(pName, mId)
301
+ }
302
+ }
303
+
304
+ const sync = getSetting('dsh-model-sync')
305
+ for (const [syncProv, syncModels] of Object.entries(sync?.modelCatalogs || {})) {
306
+ for (const m of (syncModels || [])) {
307
+ const mId = typeof m === 'string' ? m : (m?.id || m?.model)
308
+ if (mId) add(syncProv, mId)
309
+ }
310
+ }
311
+ } catch (e) {
312
+ console.warn('[dsh-moa] Error reading settings:', e)
313
+ }
314
+
315
+ writeJson(res, 200, { ok: true, models: result })
316
+ },
317
+ })
318
+ }, 'dsh-moa: models route')
319
+
320
+ ctx.effect(() => {
321
+ return ctx.webServer.register({
322
+ kind: 'exact',
323
+ path: '/dsh-moa/presets',
324
+ handler: async (req, res) => {
325
+ const cfg = getConfig()
326
+ if (req.method === 'GET') {
327
+ writeJson(res, 200, {
328
+ ok: true,
329
+ defaultPreset: cfg.default_preset || 'default',
330
+ presets: cfg.presets || [],
331
+ })
332
+ return
333
+ }
334
+
335
+ if (req.method === 'POST' || req.method === 'PUT') {
336
+ try {
337
+ const raw = await readBody(req)
338
+ const payload = JSON.parse(raw)
339
+ const newConfig = {
340
+ enabled: cfg.enabled !== false,
341
+ default_preset: payload.default_preset || cfg.default_preset || 'default',
342
+ presets: Array.isArray(payload.presets) ? payload.presets : (cfg.presets || []),
343
+ }
344
+
345
+ // 1. Persist via Cordis Settings API
346
+ if (settingsApi && typeof settingsApi.replace === 'function') {
347
+ await settingsApi.replace(newConfig)
348
+ }
349
+ currentConfig = newConfig
350
+
351
+ // 2. Direct persistence to ~/.dsh/settings.yaml
352
+ try {
353
+ const fs = await import('node:fs')
354
+ const yaml = await import('yaml')
355
+ const p = path.join(os.homedir(), '.dsh', 'settings.yaml')
356
+ if (fs.existsSync(p)) {
357
+ const curYaml = yaml.parse(fs.readFileSync(p, 'utf8')) || {}
358
+ curYaml[NS] = newConfig
359
+ fs.writeFileSync(p, yaml.stringify(curYaml), 'utf8')
360
+ }
361
+ } catch (fsErr) {
362
+ console.warn('[dsh-moa] direct file write error:', fsErr)
363
+ }
364
+
365
+ writeJson(res, 200, {
366
+ ok: true,
367
+ defaultPreset: newConfig.default_preset,
368
+ presets: newConfig.presets,
369
+ })
370
+ } catch (err) {
371
+ writeJson(res, 400, { ok: false, error: err?.message || String(err) })
372
+ }
373
+ return
374
+ }
375
+
376
+ writeJson(res, 405, { ok: false, error: 'Method Not Allowed' })
377
+ },
378
+ })
379
+ }, 'dsh-moa: presets route')
380
+
381
+ ctx.effect(() => {
382
+ return ctx.webServer.register({
383
+ kind: 'exact',
384
+ path: '/dsh-moa/run',
385
+ handler: async (req, res) => {
386
+ if (req.method !== 'POST') {
387
+ writeJson(res, 405, { ok: false, error: 'POST required' })
388
+ return
389
+ }
390
+
391
+ try {
392
+ const raw = await readBody(req)
393
+ const payload = JSON.parse(raw)
394
+ const prompt = payload.prompt || ''
395
+ if (!prompt.trim()) {
396
+ writeJson(res, 400, { ok: false, error: 'Empty prompt' })
397
+ return
398
+ }
399
+
400
+ const cfg = getConfig()
401
+ const presetName = payload.preset || cfg.default_preset || 'default'
402
+ const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
403
+
404
+ const result = await runMoAPipeline({
405
+ userPrompt: prompt,
406
+ messages: payload.messages || [],
407
+ preset,
408
+ callLlm,
409
+ cwd: payload.cwd || process.cwd(),
410
+ })
411
+
412
+ writeJson(res, 200, { ok: true, ...result })
413
+ } catch (err) {
414
+ writeJson(res, 500, { ok: false, error: err?.message || String(err) })
415
+ }
416
+ },
417
+ })
418
+ }, 'dsh-moa: run pipeline route')
419
+
420
+ // Register virtual LLM adapter for live-streaming MoA turns
421
+ if (ctx.llm && typeof ctx.llm.registerAdapter === 'function') {
422
+ ctx.effect(() => {
423
+ const adapter = new MoaRunnerAdapter((options) => {
424
+ const msgs = options.messages || []
425
+ const lastUser = [...msgs].reverse().find((m) => m && m.role === 'user')
426
+ const userText = typeof lastUser?.content === 'string'
427
+ ? lastUser.content
428
+ : (Array.isArray(lastUser?.content)
429
+ ? lastUser.content.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
430
+ : '')
431
+
432
+ const cfg = getConfig()
433
+ const parsed = parseMoACommand(userText, cfg.presets || []) || { prompt: userText, presetName: 'default' }
434
+ const targetPreset = (cfg.presets || []).find((p) => p.name === parsed.presetName) || cfg.presets?.[0]
435
+
436
+ const extraCtx = options.signal ? moaPendingContexts.get(options.signal) : null
437
+ const cwd = options.cwd || extraCtx?.cwd || process.cwd()
438
+
439
+ return {
440
+ targetPreset,
441
+ userPrompt: parsed.prompt || userText,
442
+ messages: msgs.slice(0, -1),
443
+ callLlm,
444
+ cwd,
445
+ }
446
+ })
447
+
448
+ const dispose = ctx.llm.registerAdapter(['moa-runner'], adapter)
449
+ return () => {
450
+ if (typeof dispose === 'function') dispose()
451
+ }
452
+ }, 'dsh-moa: llm adapter')
453
+ }
454
+
455
+ // Session turn interceptor: flag MoA turns and route to moa-runner adapter
456
+ const moaPendingSignals = new WeakSet()
457
+ const moaPendingContexts = new WeakMap()
458
+
459
+ ctx.on('agent/pre-step', async (event, next) => {
460
+ const messages = event.messages || []
461
+ const lastUserMsg = [...messages].reverse().find((m) => m && m.role === 'user')
462
+ const userText = typeof lastUserMsg?.content === 'string'
463
+ ? lastUserMsg.content.trim()
464
+ : (Array.isArray(lastUserMsg?.content)
465
+ ? lastUserMsg.content.filter((p) => p.type === 'text').map((p) => p.text).join('\n').trim()
466
+ : '')
467
+
468
+ if (userText.startsWith('/moa')) {
469
+ const cfg = getConfig()
470
+ if (cfg.enabled !== false) {
471
+ if (event.signal) {
472
+ moaPendingSignals.add(event.signal)
473
+ const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined)
474
+ moaPendingContexts.set(event.signal, { cwd: sessCwd })
475
+ }
476
+ }
477
+ }
478
+
479
+ return next()
480
+ })
481
+
482
+ ctx.on('agent/request', async (payload, next) => {
483
+ const resolved = await next()
484
+ if (payload.signal && moaPendingSignals.has(payload.signal)) {
485
+ moaPendingSignals.delete(payload.signal)
486
+ return {
487
+ ...resolved,
488
+ provider: 'moa-runner',
489
+ model: 'ensemble',
490
+ }
491
+ }
492
+ return resolved
493
+ })
494
+ }