@goodandready/dsh-moa 0.2.6 → 0.2.8

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/lib/client.js CHANGED
@@ -650,7 +650,27 @@ window.__ModuleLoader__.load({
650
650
  placeholder: t('judgeCriteriaPlaceholder') || 'например: Приоритет — производительность, чистый код, минимум зависимостей...',
651
651
  value: judgeCriteria,
652
652
  onChange: (e) => setJudgeCriteria(e.target.value),
653
- })
653
+ }),
654
+ React.createElement(
655
+ 'div',
656
+ { style: { marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 } },
657
+ React.createElement(
658
+ 'label',
659
+ { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500 } },
660
+ React.createElement('input', {
661
+ type: 'checkbox',
662
+ checked: currentPreset.ask_clarifying_questions !== false,
663
+ onChange: (e) => updateCurrentPreset((p) => ({ ...p, ask_clarifying_questions: e.target.checked })),
664
+ style: { accentColor: 'var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
665
+ }),
666
+ t('askQuestionsLabel') || 'Спрашивать уточняющие вопросы при неясных задачах (Опросник)'
667
+ ),
668
+ React.createElement(
669
+ 'div',
670
+ { className: 'moa-hint', style: { marginLeft: 22 } },
671
+ t('askQuestionsHint') || 'Если включено: при коротких задачах судья сформирует опросник. Если выключено: MoA сразу переходит к генерации решения.'
672
+ )
673
+ )
654
674
  )
655
675
  ),
656
676
  /* Proposers / Advisors Box with Temperature Slider */
@@ -92,7 +92,7 @@ function sanitizePath(filePath) {
92
92
  export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
93
93
  if (!baseDir) return { files: [], totalChars: 0 }
94
94
 
95
- const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh'])
95
+ const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh', '.next', '.nuxt', '.cache', 'coverage', '.turbo', '.idea', '.vscode'])
96
96
  const codeExts = new Set([
97
97
  '.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
98
98
  '.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml',
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@ import os from 'node:os'
16
16
  import {
17
17
  parseMoACommand,
18
18
  runMoAPipeline,
19
- MoaRunnerAdapter,
19
+ streamMoATurn,
20
20
  } from './moa-runner.js'
21
21
 
22
22
  import {
@@ -44,6 +44,7 @@ export const ModelSlotSchema = z.object({
44
44
  export const PresetSchema = z.object({
45
45
  name: z.string(),
46
46
  enabled: z.boolean().default(true),
47
+ ask_clarifying_questions: z.boolean().default(true),
47
48
  reference_models: z.array(ModelSlotSchema).default([]),
48
49
  aggregator: ModelSlotSchema.default({ provider: 'codex', model: 'gpt-5.6-sol' }),
49
50
  reference_temperature: z.number().default(0.6),
@@ -61,6 +62,7 @@ export const Config = z.object({
61
62
  {
62
63
  name: 'default',
63
64
  enabled: true,
65
+ ask_clarifying_questions: true,
64
66
  reference_models: [
65
67
  { provider: 'opencode-go', model: 'deepseek-v4-flash' },
66
68
  { provider: 'grok', model: 'grok-build-0.1' },
@@ -75,6 +77,7 @@ export const Config = z.object({
75
77
  {
76
78
  name: 'fast',
77
79
  enabled: true,
80
+ ask_clarifying_questions: false,
78
81
  reference_models: [
79
82
  { provider: 'opencode-go', model: 'deepseek-v4-flash' },
80
83
  ],
@@ -88,6 +91,7 @@ export const Config = z.object({
88
91
  {
89
92
  name: 'deep-reasoning',
90
93
  enabled: true,
94
+ ask_clarifying_questions: true,
91
95
  reference_models: [
92
96
  { provider: 'grok', model: 'grok-build-0.1' },
93
97
  { provider: 'commandcode', model: 'deepseek/deepseek-v4-flash' },
@@ -449,45 +453,38 @@ export function apply(ctx, config) {
449
453
  },
450
454
  })
451
455
  }, 'dsh-moa: run pipeline route')
456
+ // Session turn interceptor: route turn to real aggregator model & stream MoA synthesis
457
+ const moaPendingTurns = new WeakMap()
458
+ const moaSessionTurns = new Map()
459
+
460
+ function setSessionTurn(sessionId, turnContext) {
461
+ if (!sessionId) return
462
+ const existing = moaSessionTurns.get(sessionId)
463
+ if (existing?.ttlTimer) clearTimeout(existing.ttlTimer)
464
+
465
+ // TTL (3 minutes) to prevent memory leaks if turn is aborted or skipped before llm/stream
466
+ const ttlTimer = setTimeout(() => {
467
+ moaSessionTurns.delete(sessionId)
468
+ }, 180000)
469
+ ttlTimer.unref?.()
470
+
471
+ moaSessionTurns.set(sessionId, { ...turnContext, ttlTimer })
472
+ }
452
473
 
453
- // Register virtual LLM adapter for live-streaming MoA turns
454
- if (ctx.llm && typeof ctx.llm.registerAdapter === 'function') {
455
- ctx.effect(() => {
456
- const adapter = new MoaRunnerAdapter((options) => {
457
- const msgs = options.messages || []
458
- const lastUser = [...msgs].reverse().find((m) => m && m.role === 'user')
459
- const userText = typeof lastUser?.content === 'string'
460
- ? lastUser.content
461
- : (Array.isArray(lastUser?.content)
462
- ? lastUser.content.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
463
- : '')
464
-
465
- const cfg = live()
466
- const parsed = parseMoACommand(userText, cfg.presets || []) || { prompt: userText, presetName: 'default' }
467
- const targetPreset = (cfg.presets || []).find((p) => p.name === parsed.presetName) || cfg.presets?.[0]
468
-
469
- const extraCtx = options.signal ? moaPendingContexts.get(options.signal) : null
470
- const cwd = options.cwd || extraCtx?.cwd || process.cwd()
471
-
472
- return {
473
- targetPreset,
474
- userPrompt: parsed.prompt || userText,
475
- messages: msgs.slice(0, -1),
476
- callLlm,
477
- cwd,
478
- }
479
- })
480
-
481
- const dispose = ctx.llm.registerAdapter(['moa-runner'], adapter)
482
- return () => {
483
- if (typeof dispose === 'function') dispose()
484
- }
485
- }, 'dsh-moa: llm adapter')
474
+ function getSessionTurn(sessionId) {
475
+ if (!sessionId) return null
476
+ return moaSessionTurns.get(sessionId) || null
486
477
  }
487
478
 
488
- // Session turn interceptor: flag MoA turns and route to moa-runner adapter
489
- const moaPendingSignals = new WeakSet()
490
- const moaPendingContexts = new WeakMap()
479
+ function consumeSessionTurn(sessionId) {
480
+ if (!sessionId) return null
481
+ const entry = moaSessionTurns.get(sessionId)
482
+ if (entry) {
483
+ if (entry.ttlTimer) clearTimeout(entry.ttlTimer)
484
+ moaSessionTurns.delete(sessionId)
485
+ }
486
+ return entry
487
+ }
491
488
 
492
489
  ctx.on('agent/pre-step', async (event, next) => {
493
490
  const messages = event.messages || []
@@ -501,10 +498,26 @@ export function apply(ctx, config) {
501
498
  if (userText.startsWith('/moa')) {
502
499
  const cfg = live()
503
500
  if (cfg.enabled !== false) {
501
+ const parsed = parseMoACommand(userText, cfg.presets || []) || { prompt: userText, presetName: cfg.default_preset || 'default' }
502
+ const targetPreset = (cfg.presets || []).find((p) => p.name === parsed.presetName) || cfg.presets?.[0]
503
+ const aggProvider = targetPreset?.aggregator?.provider || 'codex'
504
+ const aggModel = targetPreset?.aggregator?.model || 'gpt-5.6-sol'
505
+ const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined) || process.cwd()
506
+
507
+ const turnContext = {
508
+ targetPreset,
509
+ userPrompt: parsed.prompt || userText,
510
+ messages: messages.slice(0, -1),
511
+ cwd: sessCwd,
512
+ aggregator: { provider: aggProvider, model: aggModel },
513
+ callLlm,
514
+ }
515
+
504
516
  if (event.signal) {
505
- moaPendingSignals.add(event.signal)
506
- const sessCwd = event.cwd || event.session?.cwd || (event.session?.path ? path.dirname(event.session.path) : undefined)
507
- moaPendingContexts.set(event.signal, { cwd: sessCwd })
517
+ moaPendingTurns.set(event.signal, turnContext)
518
+ }
519
+ if (event.session?.id) {
520
+ setSessionTurn(event.session.id, turnContext)
508
521
  }
509
522
  }
510
523
  }
@@ -514,14 +527,32 @@ export function apply(ctx, config) {
514
527
 
515
528
  ctx.on('agent/request', async (payload, next) => {
516
529
  const resolved = await next()
517
- if (payload.signal && moaPendingSignals.has(payload.signal)) {
518
- moaPendingSignals.delete(payload.signal)
530
+ const turnContext = (payload.signal ? moaPendingTurns.get(payload.signal) : null)
531
+ || (payload.session?.id ? getSessionTurn(payload.session.id) : null)
532
+
533
+ if (turnContext) {
519
534
  return {
520
535
  ...resolved,
521
- provider: 'moa-runner',
522
- model: 'ensemble',
536
+ provider: turnContext.aggregator.provider,
537
+ model: turnContext.aggregator.model,
523
538
  }
524
539
  }
525
540
  return resolved
526
541
  })
542
+
543
+ ctx.effect(() => {
544
+ return ctx.on('llm/stream', (options, next) => {
545
+ const turnContext = (options.signal ? moaPendingTurns.get(options.signal) : null)
546
+ || (options.session?.id ? consumeSessionTurn(options.session.id) : null)
547
+
548
+ if (turnContext) {
549
+ if (options.signal) moaPendingTurns.delete(options.signal)
550
+ if (options.session?.id) consumeSessionTurn(options.session.id)
551
+
552
+ return streamMoATurn(turnContext, options)
553
+ }
554
+
555
+ return next(options)
556
+ })
557
+ }, 'dsh-moa: llm stream interceptor')
527
558
  }
package/lib/moa-runner.js CHANGED
@@ -231,7 +231,7 @@ export function parseMoACommand(text, presets = []) {
231
231
  }
232
232
  }
233
233
 
234
- const matchPreset = /^--preset=([a-zA-Z0-9_-]+)\s*(.*)/s.exec(remainder)
234
+ const matchPreset = /^--preset(?:=|\s+)([a-zA-Z0-9_-]+)\s*(.*)/s.exec(remainder)
235
235
  if (matchPreset) {
236
236
  return {
237
237
  presetName: matchPreset[1],
@@ -383,7 +383,8 @@ export async function runMoAPipeline({
383
383
  }
384
384
 
385
385
  // Phase 1: Check if clarifying questions should be asked
386
- const needsQuestions = !skipQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
386
+ const allowQuestions = preset?.ask_clarifying_questions !== false && preset?.askClarifyingQuestions !== false
387
+ const needsQuestions = allowQuestions && !skipQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
387
388
  if (needsQuestions) {
388
389
  if (typeof onProgress === 'function') {
389
390
  onProgress('🔍 *Задача общего характера. Советники формируют ключевые развилки...*\n\n')
@@ -446,6 +447,32 @@ export async function runMoAPipeline({
446
447
  onProgress,
447
448
  )
448
449
 
450
+ // Fast-fail: If all candidates failed, do not call judge to save tokens and avoid synthesized hallucinations
451
+ const allFailed = referenceOutputs.length > 0 && referenceOutputs.every((r) => !r.ok)
452
+ if (allFailed) {
453
+ const errorDetails = referenceOutputs
454
+ .map((r, i) => `• Кандидат ${i + 1} (${r.label}): ${r.text}`)
455
+ .join('\n')
456
+ const failMessage = `⚠️ **Все модели-советники (${referenceOutputs.length}) завершились с ошибкой**.\n\nСудья не вызывался для предотвращения бессмысленного расхода токенов.\n\n### Детали ошибок:\n${errorDetails}`
457
+
458
+ await cleanMoaWorkspaces(workDir)
459
+
460
+ return {
461
+ kind: 'failure',
462
+ content: failMessage,
463
+ aggregator: slotLabel(aggregator),
464
+ references: referenceOutputs,
465
+ presetName: preset?.name || 'default',
466
+ promotedFiles: [],
467
+ usage: {
468
+ totalTokens: 0,
469
+ totalCostUsd: 0,
470
+ candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: 0 })),
471
+ aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
472
+ },
473
+ durationMs: Date.now() - startTime,
474
+ }
475
+ }
449
476
  // Write files for each candidate into .moa/candidate-X/
450
477
  for (let i = 0; i < referenceOutputs.length; i++) {
451
478
  const ref = referenceOutputs[i]
@@ -684,118 +711,100 @@ export function formatMoAResponse({ moaResult, presetName }) {
684
711
  return parts.join('\n')
685
712
  }
686
713
 
687
- export class MoaRunnerAdapter {
688
- constructor(getMoaContext) {
689
- this.getMoaContext = getMoaContext
690
- }
691
-
692
- providerInfo(provider) {
693
- return { id: provider, name: 'Mixture of Agents Runner' }
694
- }
695
-
696
- providerRetryPolicy(_provider) {
697
- return undefined
698
- }
699
-
700
- imageRequestPricing(_provider, _model) {
701
- return undefined
702
- }
714
+ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd }, options = {}) {
715
+ const signal = options?.signal
716
+ if (signal?.aborted) return
703
717
 
704
- async listModels(provider) {
705
- const prov = typeof provider === 'string' && provider.length > 0 ? provider : 'moa-runner'
706
- return [{ provider: prov, id: 'ensemble', name: 'MoA Ensemble' }]
707
- }
718
+ yield { type: 'block-start', index: 0, blockType: 'text' }
719
+ yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents запущен...*\n\n' }
708
720
 
709
- async resolveModel(provider, model) {
710
- return { provider, id: model, name: 'MoA Ensemble' }
711
- }
721
+ // Async push-queue for zero-latency live delta streaming
722
+ const queue = []
723
+ let notify = null
712
724
 
713
- async prepareCall(providerOrConfig, modelOrSignal, _signal) {
714
- const provider = typeof providerOrConfig === 'object' && providerOrConfig !== null ? providerOrConfig.provider : providerOrConfig
715
- const model = typeof providerOrConfig === 'object' && providerOrConfig !== null ? providerOrConfig.model : modelOrSignal
716
- return {
717
- model: { provider, id: model, name: 'MoA Ensemble' },
718
- stream: (options) => this.stream(options),
725
+ const pushUpdate = (text) => {
726
+ queue.push({ type: 'text-delta', index: 0, text })
727
+ if (notify) {
728
+ notify()
729
+ notify = null
719
730
  }
720
731
  }
721
732
 
722
- async *stream(options) {
723
- const signal = options.signal
724
- if (signal?.aborted) return
725
-
726
- yield { type: 'block-start', index: 0, blockType: 'text' }
727
- yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents запущен...*\n\n' }
728
-
729
- const { targetPreset, userPrompt, messages, callLlm, cwd } = this.getMoaContext(options)
730
-
731
- // Queue for streaming progress updates from within runMoAPipeline
732
- const progressChunks = []
733
- const onProgress = (text) => {
734
- progressChunks.push(text)
735
- }
733
+ let done = false
734
+ const pipelinePromise = runMoAPipeline({
735
+ userPrompt,
736
+ messages,
737
+ preset: targetPreset,
738
+ callLlm,
739
+ cwd,
740
+ onProgress: pushUpdate,
741
+ })
742
+ .catch((err) => ({ error: err }))
743
+ .finally(() => {
744
+ done = true
745
+ if (notify) {
746
+ notify()
747
+ notify = null
748
+ }
749
+ })
736
750
 
737
- try {
738
- const pipelinePromise = runMoAPipeline({
739
- userPrompt,
740
- messages,
741
- preset: targetPreset,
742
- callLlm,
743
- cwd,
744
- onProgress,
745
- })
751
+ const startTime = Date.now()
752
+ let lastYieldTime = Date.now()
746
753
 
747
- const startTime = Date.now()
748
- let lastYieldTime = Date.now()
754
+ try {
755
+ while (!done || queue.length > 0) {
756
+ if (signal?.aborted) {
757
+ await cleanMoaWorkspaces(cwd)
758
+ return
759
+ }
749
760
 
750
- // Yield progressive updates while pipeline runs
751
- while (true) {
752
- if (signal?.aborted) return
761
+ while (queue.length > 0) {
762
+ const item = queue.shift()
763
+ yield item
764
+ lastYieldTime = Date.now()
765
+ }
753
766
 
754
- let emittedAny = false
755
- while (progressChunks.length > 0) {
756
- const chunk = progressChunks.shift()
757
- yield { type: 'text-delta', index: 0, text: chunk }
758
- emittedAny = true
759
- lastYieldTime = Date.now()
760
- }
767
+ if (done) break
761
768
 
762
- const done = await Promise.race([
763
- pipelinePromise.then(() => true),
764
- new Promise((r) => { const t = setTimeout(() => r(false), 500); t.unref?.(); }),
765
- ])
766
- if (done) break
767
-
768
- const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
769
- // If 3 seconds passed without any progress message, send a live tick
770
- if (!emittedAny && Date.now() - lastYieldTime >= 3000) {
771
- yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
772
- lastYieldTime = Date.now()
773
- }
774
- }
769
+ // Wait for next push item or max 2.5s heartbeat
770
+ await Promise.race([
771
+ new Promise((resolve) => { notify = resolve }),
772
+ new Promise((resolve) => { const t = setTimeout(resolve, 2500); t.unref?.(); }),
773
+ ])
775
774
 
776
- while (progressChunks.length > 0) {
777
- const chunk = progressChunks.shift()
778
- yield { type: 'text-delta', index: 0, text: chunk }
775
+ const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
776
+ if (!done && Date.now() - lastYieldTime >= 3000) {
777
+ yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
778
+ lastYieldTime = Date.now()
779
779
  }
780
+ }
780
781
 
781
- const moaResult = await pipelinePromise
782
- if (signal?.aborted) return
783
-
784
- const formatted = formatMoAResponse({
785
- moaResult,
786
- presetName: targetPreset?.name || 'default',
787
- })
782
+ const result = await pipelinePromise
783
+ if (signal?.aborted) {
784
+ await cleanMoaWorkspaces(cwd)
785
+ return
786
+ }
788
787
 
789
- yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
790
- yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
791
- yield { type: 'usage', usage: { inputTokens: moaResult?.usage?.totalTokens || 100, outputTokens: formatted.length } }
792
- yield { type: 'finish', reason: { kind: 'stop' } }
793
- } catch (err) {
794
- if (signal?.aborted) return
795
- const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (err?.message || String(err))
788
+ if (result?.error) {
789
+ const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (result.error?.message || String(result.error))
796
790
  yield { type: 'text-delta', index: 0, text: errText }
797
791
  yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
798
792
  yield { type: 'finish', reason: { kind: 'stop' } }
793
+ return
794
+ }
795
+
796
+ const formatted = formatMoAResponse({
797
+ moaResult: result,
798
+ presetName: targetPreset?.name || 'default',
799
+ })
800
+
801
+ yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
802
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
803
+ yield { type: 'usage', usage: { inputTokens: result?.usage?.totalTokens || 100, outputTokens: formatted.length } }
804
+ yield { type: 'finish', reason: { kind: 'stop' } }
805
+ } finally {
806
+ if (signal?.aborted) {
807
+ await cleanMoaWorkspaces(cwd)
799
808
  }
800
809
  }
801
810
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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",