@goodandready/dsh-moa 0.2.63 → 0.2.65

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,83 @@
1
+ /**
2
+ * Official Slash Command Registration for /moa via dsh-commands service (#275)
3
+ */
4
+
5
+ import { service } from './services.js'
6
+
7
+ export function registerMoaCommands(ctx, { live, moaService }) {
8
+ const registerWithService = (cmdCtx) => {
9
+ if (!cmdCtx) return
10
+ const commands = service(cmdCtx, 'commands')
11
+ if (!commands || typeof commands.register !== 'function') return
12
+
13
+ const formatPresetsList = (cfg) => {
14
+ const presets = Array.isArray(cfg?.presets) ? cfg.presets : []
15
+ if (presets.length === 0) return 'No presets configured in dsh-moa.'
16
+ return '### 🧠 Configured MoA Presets:\n' + presets.map((p, i) =>
17
+ `- **${p.name}** (${p.id || `preset-${i + 1}`}): ${p.description || 'Consensus preset'} | ${p.references?.length || p.reference_models?.length || 0} advisors`
18
+ ).join('\n')
19
+ }
20
+
21
+ const commandHandler = async (invocation) => {
22
+ const rawText = typeof invocation === 'string' ? invocation : (invocation?.text || invocation?.raw || invocation?.args || '')
23
+ const trimmed = String(rawText).trim()
24
+
25
+ if (!trimmed || trimmed === 'help' || trimmed === '--help') {
26
+ return {
27
+ text: '🧠 **MoA Command Guide**:\n- `/moa presets` — list configured presets\n- `/moa history` — view recent runs\n- `/moa status` — check plugin status\n- `/moa [--preset <name>] <prompt>` — run MoA consensus in chat',
28
+ }
29
+ }
30
+
31
+ if (trimmed === 'presets' || trimmed === 'list') {
32
+ const cfg = typeof live === 'function' ? live() : {}
33
+ return { text: formatPresetsList(cfg) }
34
+ }
35
+
36
+ if (trimmed === 'status') {
37
+ const cfg = typeof live === 'function' ? live() : {}
38
+ return {
39
+ text: `🧠 **MoA Plugin Status**: ${cfg?.enabled !== false ? 'Enabled' : 'Disabled'} | Default preset: \`${cfg?.default_preset || 'default'}\``,
40
+ }
41
+ }
42
+
43
+ if (trimmed === 'history') {
44
+ const hist = moaService?.getHistory ? moaService.getHistory(5, 0) : { runs: [] }
45
+ if (!hist?.runs?.length) return { text: 'No MoA runs recorded in history.' }
46
+ return {
47
+ text: '### 📜 Recent MoA Runs:\n' + hist.runs.slice(0, 5).map((r) =>
48
+ `- \`${r.id?.slice(0, 8)}\` (${r.preset || 'default'}): "${r.prompt?.slice(0, 40)}..." (Winner: ${r.winnerModel || 'none'}, $${(r.totalCostUsd || 0).toFixed(4)})`
49
+ ).join('\n'),
50
+ }
51
+ }
52
+
53
+ return {
54
+ text: `🧠 MoA turn dispatched: "${trimmed}". The turn will execute with live consensus streaming.`,
55
+ }
56
+ }
57
+
58
+ const unregister = commands.register({
59
+ definitionId: '@goodandready/dsh-moa',
60
+ name: 'moa',
61
+ description: 'Run Mixture of Agents consensus or inspect presets (/moa [presets|history|status|<prompt>])',
62
+ input: { hint: '[presets|history|status|<prompt>]' },
63
+ handler: commandHandler,
64
+ execute: async (rawArgs, invocation = null) => {
65
+ const res = await commandHandler(invocation || rawArgs)
66
+ return res?.text || String(res || '')
67
+ },
68
+ })
69
+
70
+ const eff = typeof cmdCtx.effect === 'function' ? cmdCtx.effect : (fn) => fn()
71
+ eff(() => () => {
72
+ try {
73
+ if (typeof unregister === 'function') unregister()
74
+ } catch (_e) {}
75
+ }, 'dsh-moa: slash command registration')
76
+ }
77
+
78
+ if (service(ctx, 'commands')?.register) {
79
+ registerWithService(ctx)
80
+ } else if (typeof ctx.inject === 'function') {
81
+ ctx.inject(['commands'], registerWithService)
82
+ }
83
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Centralized LLM and MoA Pipeline Error Taxonomy and Classification
3
+ */
4
+
5
+ export class MoaError extends Error {
6
+ /**
7
+ * @param {string} message
8
+ * @param {object} [options]
9
+ */
10
+ constructor(message, options = {}) {
11
+ super(message)
12
+ this.name = new.target.name || 'MoaError'
13
+ this.code = options.code || 'MOA_ERROR'
14
+ this.status = typeof options.status === 'number' ? options.status : undefined
15
+ this.isRetryable = Boolean(options.isRetryable)
16
+ this.fallbackEligible = options.fallbackEligible !== false
17
+ this.cause = options.cause
18
+ }
19
+ }
20
+
21
+ export class RateLimitError extends MoaError {
22
+ constructor(message, options = {}) {
23
+ super(message, {
24
+ code: 'RATE_LIMIT_EXCEEDED',
25
+ status: 429,
26
+ isRetryable: true,
27
+ fallbackEligible: true,
28
+ ...options,
29
+ })
30
+ }
31
+ }
32
+
33
+ export class ContextOverflowError extends MoaError {
34
+ constructor(message, options = {}) {
35
+ super(message, {
36
+ code: 'CONTEXT_WINDOW_EXCEEDED',
37
+ status: 400,
38
+ isRetryable: false,
39
+ fallbackEligible: true,
40
+ ...options,
41
+ })
42
+ }
43
+ }
44
+
45
+ export class AuthenticationError extends MoaError {
46
+ constructor(message, options = {}) {
47
+ super(message, {
48
+ code: 'AUTHENTICATION_FAILED',
49
+ status: options.status || 401,
50
+ isRetryable: false,
51
+ fallbackEligible: false,
52
+ ...options,
53
+ })
54
+ }
55
+ }
56
+
57
+ export class ModelNotFoundError extends MoaError {
58
+ constructor(message, options = {}) {
59
+ super(message, {
60
+ code: 'MODEL_NOT_FOUND',
61
+ status: 404,
62
+ isRetryable: false,
63
+ fallbackEligible: true,
64
+ ...options,
65
+ })
66
+ }
67
+ }
68
+
69
+ export class TransientNetworkError extends MoaError {
70
+ constructor(message, options = {}) {
71
+ super(message, {
72
+ code: 'TRANSIENT_NETWORK_ERROR',
73
+ status: options.status || 503,
74
+ isRetryable: true,
75
+ fallbackEligible: true,
76
+ ...options,
77
+ })
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Classifies an unknown error or exception into a structured MoA domain error.
83
+ *
84
+ * @param {unknown} err
85
+ * @returns {MoaError}
86
+ */
87
+ export function classifyLlmError(err) {
88
+ if (!err) return new MoaError('Unknown error')
89
+ if (err instanceof MoaError) return err
90
+
91
+ const msg = String(err.message || err).toLowerCase()
92
+ const status = Number(err.status || err.statusCode || (msg.match(/\b(401|403|404|429|500|502|503|504)\b/)?.[1])) || undefined
93
+ const code = String(err.code || '').toUpperCase()
94
+
95
+ // Rate limit / Quota
96
+ if (
97
+ status === 429 ||
98
+ msg.includes('rate limit') ||
99
+ msg.includes('too many requests') ||
100
+ msg.includes('tpm') ||
101
+ msg.includes('rpm') ||
102
+ msg.includes('quota exceeded') ||
103
+ code.includes('RATE_LIMIT') ||
104
+ code.includes('QUOTA')
105
+ ) {
106
+ return new RateLimitError(err.message || String(err), { status: 429, cause: err })
107
+ }
108
+
109
+ // Context window overflow
110
+ if (
111
+ msg.includes('context window') ||
112
+ msg.includes('context length') ||
113
+ msg.includes('maximum context') ||
114
+ msg.includes('too long') ||
115
+ msg.includes('token limit') ||
116
+ code.includes('CONTEXT_WINDOW')
117
+ ) {
118
+ return new ContextOverflowError(err.message || String(err), { status: 400, cause: err })
119
+ }
120
+
121
+ // Authentication & permissions
122
+ if (
123
+ status === 401 ||
124
+ status === 403 ||
125
+ msg.includes('unauthorized') ||
126
+ msg.includes('invalid api key') ||
127
+ msg.includes('forbidden') ||
128
+ msg.includes('authentication') ||
129
+ code.includes('AUTH') ||
130
+ code.includes('INVALID_CREDENTIAL')
131
+ ) {
132
+ return new AuthenticationError(err.message || String(err), { status: status || 401, cause: err })
133
+ }
134
+
135
+ // Model not found / unavailable
136
+ if (
137
+ status === 404 ||
138
+ msg.includes('model not found') ||
139
+ msg.includes('does not exist') ||
140
+ msg.includes('unknown model') ||
141
+ code.includes('MODEL_NOT_FOUND')
142
+ ) {
143
+ return new ModelNotFoundError(err.message || String(err), { status: 404, cause: err })
144
+ }
145
+
146
+ // Transient network / gateway / timeout
147
+ if (
148
+ status === 502 ||
149
+ status === 503 ||
150
+ status === 504 ||
151
+ msg.includes('timeout') ||
152
+ msg.includes('econnreset') ||
153
+ msg.includes('etimedout') ||
154
+ msg.includes('network') ||
155
+ msg.includes('socket hang up') ||
156
+ msg.includes('service unavailable') ||
157
+ msg.includes('bad gateway') ||
158
+ code.includes('TIMEOUT') ||
159
+ code.includes('ECONNRESET')
160
+ ) {
161
+ return new TransientNetworkError(err.message || String(err), { status: status || 503, cause: err })
162
+ }
163
+
164
+ return new MoaError(err.message || String(err), { status, cause: err })
165
+ }
166
+
167
+ /**
168
+ * Returns true if the error qualifies for candidate failover to a reserve model.
169
+ *
170
+ * @param {unknown} err
171
+ * @returns {boolean}
172
+ */
173
+ export function isFailoverEligible(err) {
174
+ const classified = classifyLlmError(err)
175
+ return classified.fallbackEligible
176
+ }
package/lib/events.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cordis Event Bus Definitions and Helpers for dsh-moa
3
+ */
4
+
5
+ export const MOA_EVENTS = {
6
+ RUN_START: 'moa/run-start',
7
+ CANDIDATE_COMPLETE: 'moa/candidate-complete',
8
+ CRITIQUE_COMPLETE: 'moa/critique-complete',
9
+ JUDGE_VERDICT: 'moa/judge-verdict',
10
+ RUN_FINISH: 'moa/run-finish',
11
+ }
12
+
13
+ /**
14
+ * Dispatches an event onto the Cordis event bus with best-effort error isolation.
15
+ * If ctx or ctx.emit is not available, or an event listener throws, the pipeline is not interrupted.
16
+ *
17
+ * @param {object} [ctx] - Cordis context or mock context
18
+ * @param {string} eventName - One of MOA_EVENTS
19
+ * @param {object} payload - Event data payload
20
+ */
21
+ export function emitEvent(ctx, eventName, payload) {
22
+ if (!ctx || !eventName) return
23
+ try {
24
+ if (typeof ctx.emit === 'function') {
25
+ ctx.emit(eventName, payload)
26
+ }
27
+ } catch (_err) {
28
+ /* best-effort event dispatch: listener exceptions must never abort pipeline */
29
+ }
30
+ }
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerMoaCommands } from './commands.js'
1
2
  import { service, getWebServerPort } from './services.js'
2
3
  import crypto from 'node:crypto'
3
4
  import path from 'node:path'
@@ -22,6 +23,9 @@ import { createLiveCanvasClient } from './live-canvas.js'
22
23
  import { registerPluginUpdater } from './updater.js'
23
24
  import { bestEffort } from './best-effort.js'
24
25
  import { getMoaHistory } from './history.js'
26
+ import { runMoAPipeline } from './moa-runner.js'
27
+ import { MoaService } from './moa-service.js'
28
+ import { MOA_EVENTS, emitEvent } from './events.js'
25
29
 
26
30
  export const name = '@goodandready/dsh-moa'
27
31
  export { collectProjectContext, formatProjectContext } from './file-workspace.js'
@@ -132,6 +136,23 @@ export function apply(ctx, config) {
132
136
  // required to be installed).
133
137
  const liveCanvas = createLiveCanvasClient({ getPort: () => getWebServerPort(ctx) })
134
138
 
139
+ // Native Cordis service: ctx.moa
140
+ const moaService = new MoaService(ctx, {
141
+ live,
142
+ runMoAPipeline,
143
+ resolvePresetForPrompt,
144
+ getMoaHistory,
145
+ callLlm,
146
+ liveCanvas,
147
+ get webServerPort() { return getWebServerPort(ctx) },
148
+ get timeMachineEngine() { return service(ctx, 'timeMachineEngine') },
149
+ })
150
+
151
+ if (typeof ctx.provide === 'function') {
152
+ ctx.provide('moa', moaService)
153
+ }
154
+ ctx.moa = moaService
155
+
135
156
  // Route: /dsh-moa/status & one-click updater
136
157
  const registerWebSurfaces = (webCtx) => {
137
158
  if (!webCtx) return
@@ -165,6 +186,9 @@ export function apply(ctx, config) {
165
186
  ctx.inject(['webServer'], registerWebSurfaces)
166
187
  }
167
188
 
189
+ // Official slash command /moa registration via dsh-commands (#275)
190
+ registerMoaCommands(ctx, { live, moaService })
191
+
168
192
  // Session turn interceptor: route turn to real aggregator model & stream MoA synthesis
169
193
  ctx.effect(() => {
170
194
  return () => {
@@ -284,6 +308,7 @@ export function apply(ctx, config) {
284
308
  liveCanvas,
285
309
  webServerPort: getWebServerPort(ctx),
286
310
  timeMachineEngine: service(ctx, 'timeMachineEngine') || ctx.reflect?.get?.('timeMachineEngine', false),
311
+ ctx,
287
312
  }
288
313
 
289
314
  if (event.signal) {
@@ -337,6 +362,8 @@ export function apply(ctx, config) {
337
362
  }
338
363
 
339
364
  export { runMoAPipeline } from './moa-runner.js'
365
+ export { MoaService } from './moa-service.js'
366
+ export { MOA_EVENTS, emitEvent } from './events.js'
340
367
  export { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
341
368
  export {
342
369
  COMPANION_PLUGINS,
@@ -347,3 +374,14 @@ export {
347
374
  handleWorkbenchGet,
348
375
  handleWorkbenchAssemble,
349
376
  } from './moa-workbench.js'
377
+ export { registerMoaCommands } from './commands.js'
378
+ export {
379
+ MoaError,
380
+ RateLimitError,
381
+ ContextOverflowError,
382
+ AuthenticationError,
383
+ ModelNotFoundError,
384
+ TransientNetworkError,
385
+ classifyLlmError,
386
+ isFailoverEligible,
387
+ } from './errors.js'
@@ -1,3 +1,4 @@
1
+ import { classifyLlmError, isFailoverEligible } from './errors.js'
1
2
  import { logger } from './logger.js'
2
3
  /**
3
4
  * DeepSeek Harness Mixture of Agents (MoA) — Candidate Fan-Out & Retry
@@ -18,14 +19,13 @@ export async function callWithTransientRetry(callLlmFn, callArgs, maxRetries = 0
18
19
  return await callLlmFn(callArgs)
19
20
  } catch (err) {
20
21
  attempt++
21
- const msg = err?.message || String(err)
22
- const isTransient = /429|rate limit|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
23
- if (attempt <= maxRetries && isTransient && !callArgs?.signal?.aborted) {
22
+ const classified = classifyLlmError(err)
23
+ if (attempt <= maxRetries && classified.isRetryable && !callArgs?.signal?.aborted) {
24
24
  await new Promise((r) => setTimeout(r, retryDelayMs))
25
25
  if (callArgs?.signal?.aborted) throw new Error('Aborted')
26
26
  continue
27
27
  }
28
- throw err
28
+ throw classified
29
29
  }
30
30
  }
31
31
  }
@@ -100,6 +100,7 @@ export async function runReferencesParallel(references, messages, options = {},
100
100
 
101
101
  const runOne = async () => {
102
102
  let candidateTimer = null
103
+ const candidateStart = Date.now()
103
104
  try {
104
105
  const callPromise = callWithTransientRetry(
105
106
  callLlm,
@@ -144,6 +145,8 @@ export async function runReferencesParallel(references, messages, options = {},
144
145
  text,
145
146
  usage: costInfo,
146
147
  costUsd: costInfo.costUsd,
148
+ durationMs: Date.now() - candidateStart,
149
+ model: slot?.model ? (slot.provider ? (slot.provider + ":" + slot.model) : slot.model) : label,
147
150
  ok: true,
148
151
  }
149
152
 
@@ -173,10 +176,29 @@ export async function runReferencesParallel(references, messages, options = {},
173
176
  return
174
177
  }
175
178
 
176
- // Feature 10: Local fallback models if online candidate fails
179
+ const classified = classifyLlmError(err)
180
+ const canFailover = isFailoverEligible(classified)
181
+
182
+ // Reserve model failover: checks slot.fallbacks, options.fallback_models, or options.local_fallback_models
177
183
  let fallbackRecovered = false
178
- if (options.local_fallback_enabled && Array.isArray(options.local_fallback_models) && options.local_fallback_models.length > 0) {
179
- for (const fbSlot of options.local_fallback_models) {
184
+ const fallbackCandidates = [
185
+ ...(Array.isArray(slot?.fallbacks) ? slot.fallbacks : []),
186
+ ...(Array.isArray(options.fallback_models) ? options.fallback_models : []),
187
+ ...(options.local_fallback_enabled && Array.isArray(options.local_fallback_models) ? options.local_fallback_models : []),
188
+ ].filter(Boolean)
189
+
190
+ const seenFb = new Set([`${slot.provider}:${slot.model}`])
191
+ const eligibleFallbacks = []
192
+ for (const fb of fallbackCandidates) {
193
+ const key = `${fb.provider}:${fb.model}`
194
+ if (!seenFb.has(key)) {
195
+ seenFb.add(key)
196
+ eligibleFallbacks.push(fb)
197
+ }
198
+ }
199
+
200
+ if (canFailover && eligibleFallbacks.length > 0) {
201
+ for (const fbSlot of eligibleFallbacks) {
180
202
  if (options.signal?.aborted) break
181
203
  const fbLabel = slotLabel(fbSlot)
182
204
  const fbAbortCtrl = new AbortController()
@@ -195,7 +217,7 @@ export async function runReferencesParallel(references, messages, options = {},
195
217
 
196
218
  try {
197
219
  if (typeof onProgress === 'function') {
198
- onProgress(`🔄 *Candidate ${i + 1} (${label}) failed, attempting local fallback to ${fbLabel}...*\n`)
220
+ onProgress(`🔄 *Candidate ${i + 1} (${label}) hit ${classified.name}, failover to ${fbLabel}...*\n`)
199
221
  }
200
222
  const fbCallPromise = callWithTransientRetry(
201
223
  callLlm,
@@ -240,14 +262,15 @@ export async function runReferencesParallel(references, messages, options = {},
240
262
  ok: true,
241
263
  was_fallback: true,
242
264
  original_model: label,
265
+ failoverReason: classified.code,
243
266
  }
244
267
  fallbackRecovered = true
245
268
  if (typeof onProgress === 'function') {
246
- onProgress(`✅ *Candidate ${i + 1}/${total} recovered using fallback (${fbLabel})*\n`)
269
+ onProgress(`✅ *Candidate ${i + 1}/${total} recovered using failover (${fbLabel})*\n`)
247
270
  }
248
271
  break
249
272
  } catch (fbErr) {
250
- logger.warn(`[dsh-moa] Fallback ${fbLabel} also failed:`, fbErr?.message || fbErr)
273
+ logger.warn(`[dsh-moa] Reserve model ${fbLabel} also failed:`, fbErr?.message || fbErr)
251
274
  } finally {
252
275
  if (fbTimer) clearTimeout(fbTimer)
253
276
  if (removeParentAbort) removeParentAbort()
@@ -257,7 +280,7 @@ export async function runReferencesParallel(references, messages, options = {},
257
280
 
258
281
  if (!fallbackRecovered) {
259
282
  finishedCount++
260
- const errMsg = err?.message || String(err)
283
+ const errMsg = classified?.message || err?.message || String(err)
261
284
  logger.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
262
285
  if (typeof onProgress === 'function') {
263
286
  onProgress(`⚠️ *Candidate ${i + 1}/${total} (${label}) error: ${errMsg}*\n`)
@@ -273,6 +296,8 @@ export async function runReferencesParallel(references, messages, options = {},
273
296
  costUsd: 0,
274
297
  ok: false,
275
298
  error: errMsg,
299
+ errorClass: classified.name,
300
+ errorCode: classified.code,
276
301
  }
277
302
  }
278
303
  } finally {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Native Cordis Service: ctx.moa
3
+ * Allows in-process MoA consensus pipeline execution and telemetry by peer plugins.
4
+ */
5
+
6
+ import { emitEvent, MOA_EVENTS } from './events.js'
7
+
8
+ export class MoaService {
9
+ /**
10
+ * @param {import('@deepseek-ai/cordis').Context} ctx
11
+ * @param {object} [deps={}]
12
+ */
13
+ constructor(ctx, deps = {}) {
14
+ this.ctx = ctx
15
+ this.deps = deps
16
+ }
17
+
18
+ get live() {
19
+ return typeof this.deps.live === 'function' ? this.deps.live() : (this.deps.live || {})
20
+ }
21
+
22
+ /**
23
+ * Get all active configured presets
24
+ * @returns {Array<object>}
25
+ */
26
+ getPresets() {
27
+ const cfg = this.live
28
+ return Array.isArray(cfg?.presets) ? [...cfg.presets] : []
29
+ }
30
+
31
+ /**
32
+ * Get preset by ID or name
33
+ * @param {string} [idOrName]
34
+ * @returns {object|null}
35
+ */
36
+ getPreset(idOrName) {
37
+ const presets = this.getPresets()
38
+ if (!idOrName) return presets[0] || null
39
+ return presets.find((p) => p.id === idOrName || p.name === idOrName) || presets[0] || null
40
+ }
41
+
42
+ /**
43
+ * Get historical MoA runs
44
+ * @param {number} [limit=20]
45
+ * @param {number} [offset=0]
46
+ * @returns {object}
47
+ */
48
+ getHistory(limit = 20, offset = 0) {
49
+ const getHistoryFn = this.deps.getMoaHistory
50
+ if (typeof getHistoryFn === 'function') {
51
+ return getHistoryFn(limit, offset)
52
+ }
53
+ return { runs: [], total: 0 }
54
+ }
55
+
56
+ /**
57
+ * Resolve best preset for a given user prompt
58
+ * @param {string} prompt
59
+ * @returns {Promise<object>}
60
+ */
61
+ async resolvePresetForPrompt(prompt) {
62
+ const resolveFn = this.deps.resolvePresetForPrompt
63
+ if (typeof resolveFn === 'function') {
64
+ return resolveFn({ prompt, live: () => this.live, callLlm: this.deps.callLlm })
65
+ }
66
+ return { preset: this.getPreset() }
67
+ }
68
+
69
+ /**
70
+ * Run MoA consensus pipeline in-process
71
+ * @param {object} options
72
+ * @returns {Promise<object>}
73
+ */
74
+ async run(options = {}) {
75
+ const runFn = this.deps.runMoAPipeline
76
+ if (typeof runFn !== 'function') {
77
+ throw new Error('[dsh-moa] runMoAPipeline is not available in MoaService')
78
+ }
79
+
80
+ const {
81
+ prompt,
82
+ userPrompt,
83
+ messages = [],
84
+ preset: customPreset,
85
+ presetId,
86
+ cwd = process.cwd(),
87
+ signal,
88
+ onProgress,
89
+ onStreamDelta,
90
+ checkpoint,
91
+ force,
92
+ testGateEnabled,
93
+ testCommand,
94
+ } = options
95
+
96
+ const effectivePrompt = prompt || userPrompt || (messages[messages.length - 1]?.content) || ''
97
+ if (!effectivePrompt && messages.length === 0) {
98
+ throw new Error('[dsh-moa] prompt or userPrompt is required to run MoA')
99
+ }
100
+
101
+ let preset = customPreset
102
+ if (!preset) {
103
+ if (presetId) {
104
+ preset = this.getPreset(presetId)
105
+ } else {
106
+ const routed = await this.resolvePresetForPrompt(effectivePrompt)
107
+ preset = routed?.preset || this.getPreset()
108
+ }
109
+ }
110
+
111
+ const cfg = this.live
112
+ return runFn({
113
+ userPrompt: effectivePrompt,
114
+ messages,
115
+ preset,
116
+ callLlm: options.callLlm || this.deps.callLlm,
117
+ cwd,
118
+ prices: options.prices || cfg?.prices || this.deps.prices || {},
119
+ onProgress,
120
+ onStreamDelta,
121
+ signal,
122
+ checkpoint,
123
+ force,
124
+ testGateEnabled,
125
+ testCommand,
126
+ liveCanvas: options.liveCanvas || this.deps.liveCanvas,
127
+ webServerPort: options.webServerPort || this.deps.webServerPort,
128
+ timeMachineEngine: options.timeMachineEngine || this.deps.timeMachineEngine,
129
+ historyFilePath: options.historyFilePath || this.deps.historyFilePath,
130
+ ctx: this.ctx,
131
+ })
132
+ }
133
+
134
+ /**
135
+ * Attach listener to MoA events on the underlying Cordis Context
136
+ * @param {string} event
137
+ * @param {Function} listener
138
+ * @returns {Function|undefined}
139
+ */
140
+ on(event, listener) {
141
+ if (typeof this.ctx?.on === 'function') {
142
+ return this.ctx.on(event, listener)
143
+ }
144
+ return undefined
145
+ }
146
+
147
+ /**
148
+ * Emit an event on the Cordis event bus
149
+ * @param {string} event
150
+ * @param {any} payload
151
+ */
152
+ emit(event, payload) {
153
+ emitEvent(this.ctx, event, payload)
154
+ }
155
+ }
package/lib/moa-stream.js CHANGED
@@ -11,7 +11,7 @@ import { runMoAPipeline } from './moa-runner.js'
11
11
  /**
12
12
  * Streams a full MoA turn into chat with live aggregator tokens and progress feedback.
13
13
  */
14
- export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas, webServerPort, timeMachineEngine, force }, options = {}) {
14
+ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas, webServerPort, timeMachineEngine, force, ctx }, options = {}) {
15
15
  const signal = options?.signal
16
16
  const runId = options?.runId || null
17
17
  if (signal?.aborted) return
@@ -58,6 +58,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
58
58
  force,
59
59
  signal: combinedSignal,
60
60
  runId,
61
+ ctx,
61
62
  })
62
63
  .catch((err) => ({ error: err }))
63
64
  .finally(() => {
@@ -14,8 +14,10 @@ export async function stageProjectContext({
14
14
  startTime,
15
15
  onProgress,
16
16
  }) {
17
- const referenceModels = Array.isArray(preset?.reference_models) && preset.reference_models.length > 0
18
- ? preset.reference_models : [{ provider: '', model: '' }]
17
+ const rawRefs = (Array.isArray(preset?.reference_models) && preset.reference_models.length > 0)
18
+ ? preset.reference_models
19
+ : (Array.isArray(preset?.references) && preset.references.length > 0 ? preset.references : [])
20
+ const referenceModels = rawRefs.length > 0 ? rawRefs : [{ provider: '', model: '' }]
19
21
  const primaryJudge = preset?.aggregator?.provider && preset?.aggregator?.model
20
22
  ? preset.aggregator : { provider: '', model: '' }
21
23
  const fallbackJudges = Array.isArray(preset?.aggregator_fallbacks) ? preset.aggregator_fallbacks : []
@@ -2,6 +2,7 @@ import { logger } from '../logger.js'
2
2
  import { promoteCandidateWorkspace, cleanMoaWorkspaces } from '../workspace/fs.js'
3
3
  import { createPromotedPreview } from '../live-canvas.js'
4
4
  import { recordMoaRunAsync, candidatesForHistory } from '../history.js'
5
+ import { emitEvent, MOA_EVENTS } from '../events.js'
5
6
 
6
7
  export async function stageFastMode({
7
8
  successfulRefs,
@@ -18,6 +19,7 @@ export async function stageFastMode({
18
19
  startTime,
19
20
  collectedCtx,
20
21
  liveCanvas,
22
+ ctx = null,
21
23
  }) {
22
24
  if (signal?.aborted) {
23
25
  await cleanMoaWorkspaces(cwd, { runId })
@@ -51,6 +53,9 @@ export async function stageFastMode({
51
53
  } catch (histErr) {
52
54
  logger.warn('[dsh-moa] Failed to record fast synthesis failure in history:', histErr?.message || histErr)
53
55
  }
56
+ emitEvent(ctx, MOA_EVENTS.RUN_FINISH, {
57
+ runId, durationMs, totalTokens, totalCostUsd, promotedFiles: [], winnerIndex: 0, error: promoteErr?.message || String(promoteErr),
58
+ })
54
59
  return {
55
60
  ok: false,
56
61
  payload: {
@@ -87,6 +92,9 @@ export async function stageFastMode({
87
92
  logger.warn('[dsh-moa] Failed to record fast synthesis run in history:', histErr?.message || histErr)
88
93
  }
89
94
 
95
+ emitEvent(ctx, MOA_EVENTS.RUN_FINISH, {
96
+ runId, durationMs, totalTokens, totalCostUsd, promotedFiles, winnerIndex: single.index,
97
+ })
90
98
  return {
91
99
  ok: true,
92
100
  payload: {
@@ -109,5 +109,5 @@ export async function stagePeerCritique({
109
109
  })
110
110
  }
111
111
 
112
- return { ok: true }
112
+ return { ok: true, critiquesCount: runRound2 ? successfulRefs.length : 0 }
113
113
  }
@@ -7,6 +7,7 @@ import { generateMoABenchmarkReport } from '../moa-report.js'
7
7
  import { parseFileScores } from '../moa-workbench.js'
8
8
  import { recordMoaRunAsync, candidatesForHistory } from '../history.js'
9
9
  import { slotLabel } from '../moa-prompts.js'
10
+ import { emitEvent, MOA_EVENTS } from '../events.js'
10
11
 
11
12
  export async function stagePromotion({
12
13
  synthesizedText,
@@ -31,6 +32,7 @@ export async function stagePromotion({
31
32
  referenceModels,
32
33
  historyFilePath,
33
34
  collectedCtx,
35
+ ctx = null,
34
36
  }) {
35
37
  const winningIndex = (multiJudgeResult?.consensus && multiJudgeResult?.winningCandidateIndex)
36
38
  ? multiJudgeResult.winningCandidateIndex
@@ -89,6 +91,13 @@ export async function stagePromotion({
89
91
  ? parseFileScores(synthesizedText, referenceOutputs)
90
92
  : null
91
93
 
94
+ emitEvent(ctx, MOA_EVENTS.JUDGE_VERDICT, {
95
+ runId,
96
+ winningIndex,
97
+ winningModel: winnerModel,
98
+ scores: fileScores || multiJudgeResult?.scores || null,
99
+ })
100
+
92
101
  try {
93
102
  await recordMoaRunAsync({
94
103
  id: runId, cwd, prompt: userPrompt, preset: preset?.name || 'default', isRefinement,
@@ -101,6 +110,15 @@ export async function stagePromotion({
101
110
  logger.warn('[dsh-moa] Failed to record run in history:', histErr)
102
111
  }
103
112
 
113
+ emitEvent(ctx, MOA_EVENTS.RUN_FINISH, {
114
+ runId,
115
+ durationMs,
116
+ totalTokens: usageSummary.totalTokens,
117
+ totalCostUsd: usageSummary.totalCostUsd,
118
+ promotedFiles,
119
+ winnerIndex: winningIndex,
120
+ })
121
+
104
122
  return {
105
123
  ok: true,
106
124
  payload: {
@@ -11,6 +11,7 @@ import { stagePeerCritique } from './peer-critique.js'
11
11
  import { stageMultiJudge } from './multi-judge.js'
12
12
  import { stageSynthesis } from './synthesis.js'
13
13
  import { stagePromotion } from './promotion.js'
14
+ import { emitEvent, MOA_EVENTS } from '../events.js'
14
15
 
15
16
  export async function runMoAPipeline({
16
17
  userPrompt,
@@ -22,12 +23,21 @@ export async function runMoAPipeline({
22
23
  testGateEnabled = null, testCommand = null, execFn = null,
23
24
  webServerPort = null, timeMachineEngine = null, timeMachineUrl = null,
24
25
  force = isTestEnvironment() ? true : false, checkpoint = true, checkpointFn = null, runId: customRunId = null,
26
+ ctx = null,
25
27
  }) {
26
28
  const runId = (customRunId && String(customRunId).trim()) || crypto.randomUUID()
27
29
  const promoteOpts = { webServerPort, timeMachineEngine, timeMachineUrl, force: Boolean(force), checkpoint, checkpointFn, signal }
28
30
  if (signal?.aborted) return { error: new Error('Turn aborted') }
29
31
  const startTime = Date.now()
30
32
 
33
+ emitEvent(ctx, MOA_EVENTS.RUN_START, {
34
+ runId,
35
+ prompt: userPrompt,
36
+ preset: preset?.name || preset?.id || 'default',
37
+ candidatesCount: Array.isArray(preset?.references) ? preset.references.length : 0,
38
+ timestamp: new Date(startTime).toISOString(),
39
+ })
40
+
31
41
  // Stage 1: Context & Configuration
32
42
  const ctxStage = await stageProjectContext({
33
43
  userPrompt, messages, preset, cwd, prices, maxTokens: preset?.max_tokens, startTime, onProgress,
@@ -54,10 +64,30 @@ export async function runMoAPipeline({
54
64
  })
55
65
  if (!candStage.ok) {
56
66
  if (candStage.abort) return { error: candStage.error }
67
+ emitEvent(ctx, MOA_EVENTS.RUN_FINISH, {
68
+ runId,
69
+ durationMs: Date.now() - startTime,
70
+ totalTokens: candStage.failurePayload?.usage?.totalTokens || 0,
71
+ totalCostUsd: candStage.failurePayload?.usage?.totalCostUsd || 0,
72
+ promotedFiles: [],
73
+ winnerIndex: 0,
74
+ error: candStage.failurePayload?.content,
75
+ })
57
76
  return candStage.failurePayload
58
77
  }
59
78
  const { referenceOutputs, successfulRefs } = candStage
60
79
 
80
+ for (const ref of referenceOutputs) {
81
+ emitEvent(ctx, MOA_EVENTS.CANDIDATE_COMPLETE, {
82
+ runId,
83
+ round: 1,
84
+ model: ref.model || ref.label,
85
+ success: Boolean(ref.ok),
86
+ durationMs: ref.durationMs || 0,
87
+ tokens: ref.usage?.totalTokens || 0,
88
+ })
89
+ }
90
+
61
91
  // Stage 3: Clarifying Questions Branch
62
92
  if (ctxStage.needsQuestions) {
63
93
  return await stageClarifyingQuestions({
@@ -75,7 +105,7 @@ export async function runMoAPipeline({
75
105
  allowCandidateOverride: ctxStage.allowCandidateOverride,
76
106
  promoteOpts, cwd, runId, signal, userPrompt, preset,
77
107
  isRefinement: ctxStage.isRefinement, historyFilePath, startTime,
78
- collectedCtx: ctxStage.collectedCtx, liveCanvas,
108
+ collectedCtx: ctxStage.collectedCtx, liveCanvas, ctx,
79
109
  })
80
110
  if (!fastRes.ok) {
81
111
  if (fastRes.abort) return { error: fastRes.error }
@@ -102,6 +132,12 @@ export async function runMoAPipeline({
102
132
  options: { testGateEnabled, testCommand, execFn },
103
133
  })
104
134
  if (!peerRes.ok) return peerRes.abortPayload
135
+ if (ctxStage.isPeerCritiqueEnabled && peerRes.ok && peerRes.critiquesCount > 0) {
136
+ emitEvent(ctx, MOA_EVENTS.CRITIQUE_COMPLETE, {
137
+ runId,
138
+ critiquesCount: peerRes.critiquesCount,
139
+ })
140
+ }
105
141
 
106
142
  if (signal?.aborted) {
107
143
  await cleanMoaWorkspaces(cwd, { runId })
@@ -153,7 +189,7 @@ export async function runMoAPipeline({
153
189
  promoteOpts, cwd, runId, signal, liveCanvas, startTime, chosenJudge, aggUsage,
154
190
  userPrompt, preset, isRefinement: ctxStage.isRefinement, isFastMode: ctxStage.isFastMode,
155
191
  effectiveReferences: ctxStage.effectiveReferences, referenceModels: ctxStage.referenceModels,
156
- historyFilePath, collectedCtx: ctxStage.collectedCtx,
192
+ historyFilePath, collectedCtx: ctxStage.collectedCtx, ctx,
157
193
  })
158
194
  if (!promRes.ok) {
159
195
  if (promRes.abort) return { error: promRes.error }
@@ -73,6 +73,7 @@ export function registerPipelineRoutes(ctx, { live, callLlm, liveCanvas, webServ
73
73
  timeMachineUrl: payload.timeMachineUrl, force: false,
74
74
  checkpoint: true,
75
75
  runId: payload.runId,
76
+ ctx,
76
77
  })
77
78
 
78
79
  if (result?.skippedFiles > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",