mcp-rubber-duck 1.9.5 → 1.11.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.
- package/.eslintrc.json +3 -1
- package/CHANGELOG.md +19 -0
- package/README.md +62 -10
- package/assets/ext-apps-compare.png +0 -0
- package/assets/ext-apps-debate.png +0 -0
- package/assets/ext-apps-usage-stats.png +0 -0
- package/assets/ext-apps-vote.png +0 -0
- package/audit-ci.json +2 -1
- package/dist/providers/enhanced-manager.d.ts +7 -0
- package/dist/providers/enhanced-manager.d.ts.map +1 -1
- package/dist/providers/enhanced-manager.js +36 -0
- package/dist/providers/enhanced-manager.js.map +1 -1
- package/dist/providers/manager.d.ts +1 -0
- package/dist/providers/manager.d.ts.map +1 -1
- package/dist/providers/manager.js +33 -0
- package/dist/providers/manager.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +154 -36
- package/dist/server.js.map +1 -1
- package/dist/services/progress.d.ts +27 -0
- package/dist/services/progress.d.ts.map +1 -0
- package/dist/services/progress.js +50 -0
- package/dist/services/progress.js.map +1 -0
- package/dist/services/task-manager.d.ts +56 -0
- package/dist/services/task-manager.d.ts.map +1 -0
- package/dist/services/task-manager.js +134 -0
- package/dist/services/task-manager.js.map +1 -0
- package/dist/tools/compare-ducks.d.ts +2 -1
- package/dist/tools/compare-ducks.d.ts.map +1 -1
- package/dist/tools/compare-ducks.js +26 -3
- package/dist/tools/compare-ducks.js.map +1 -1
- package/dist/tools/duck-council.d.ts +2 -1
- package/dist/tools/duck-council.d.ts.map +1 -1
- package/dist/tools/duck-council.js +7 -3
- package/dist/tools/duck-council.js.map +1 -1
- package/dist/tools/duck-debate.d.ts +2 -1
- package/dist/tools/duck-debate.d.ts.map +1 -1
- package/dist/tools/duck-debate.js +43 -1
- package/dist/tools/duck-debate.js.map +1 -1
- package/dist/tools/duck-iterate.d.ts +2 -1
- package/dist/tools/duck-iterate.d.ts.map +1 -1
- package/dist/tools/duck-iterate.js +13 -1
- package/dist/tools/duck-iterate.js.map +1 -1
- package/dist/tools/duck-vote.d.ts +2 -1
- package/dist/tools/duck-vote.d.ts.map +1 -1
- package/dist/tools/duck-vote.js +30 -3
- package/dist/tools/duck-vote.js.map +1 -1
- package/dist/tools/get-usage-stats.d.ts.map +1 -1
- package/dist/tools/get-usage-stats.js +13 -0
- package/dist/tools/get-usage-stats.js.map +1 -1
- package/dist/ui/compare-ducks/mcp-app.html +187 -0
- package/dist/ui/duck-debate/mcp-app.html +182 -0
- package/dist/ui/duck-vote/mcp-app.html +168 -0
- package/dist/ui/usage-stats/mcp-app.html +192 -0
- package/jest.config.js +1 -0
- package/package.json +7 -3
- package/src/providers/enhanced-manager.ts +49 -0
- package/src/providers/manager.ts +45 -0
- package/src/server.ts +187 -34
- package/src/services/progress.ts +59 -0
- package/src/services/task-manager.ts +162 -0
- package/src/tools/compare-ducks.ts +34 -3
- package/src/tools/duck-council.ts +15 -4
- package/src/tools/duck-debate.ts +58 -1
- package/src/tools/duck-iterate.ts +20 -1
- package/src/tools/duck-vote.ts +38 -3
- package/src/tools/get-usage-stats.ts +14 -0
- package/src/ui/compare-ducks/app.ts +88 -0
- package/src/ui/compare-ducks/mcp-app.html +102 -0
- package/src/ui/duck-debate/app.ts +111 -0
- package/src/ui/duck-debate/mcp-app.html +97 -0
- package/src/ui/duck-vote/app.ts +128 -0
- package/src/ui/duck-vote/mcp-app.html +83 -0
- package/src/ui/usage-stats/app.ts +156 -0
- package/src/ui/usage-stats/mcp-app.html +107 -0
- package/tests/duck-debate.test.ts +83 -1
- package/tests/duck-iterate.test.ts +81 -0
- package/tests/duck-vote.test.ts +73 -1
- package/tests/providers.test.ts +121 -0
- package/tests/services/progress.test.ts +137 -0
- package/tests/services/task-manager.test.ts +344 -0
- package/tests/tools/compare-ducks-ui.test.ts +135 -0
- package/tests/tools/compare-ducks.test.ts +22 -1
- package/tests/tools/duck-council.test.ts +19 -0
- package/tests/tools/duck-debate-ui.test.ts +234 -0
- package/tests/tools/duck-vote-ui.test.ts +172 -0
- package/tests/tools/get-usage-stats.test.ts +3 -1
- package/tests/tools/usage-stats-ui.test.ts +130 -0
- package/tests/ui-build.test.ts +53 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task lifecycle adapter wrapping the MCP SDK's experimental Tasks API.
|
|
3
|
+
*
|
|
4
|
+
* This module isolates all direct usage of `@modelcontextprotocol/sdk/experimental`
|
|
5
|
+
* behind a single adapter class so that future breaking changes in the experimental
|
|
6
|
+
* API only require updates here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
InMemoryTaskStore,
|
|
11
|
+
InMemoryTaskMessageQueue,
|
|
12
|
+
} from '@modelcontextprotocol/sdk/experimental';
|
|
13
|
+
import type {
|
|
14
|
+
TaskStore,
|
|
15
|
+
TaskMessageQueue,
|
|
16
|
+
} from '@modelcontextprotocol/sdk/experimental';
|
|
17
|
+
import type { CallToolResult, Result } from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
+
import { logger } from '../utils/logger.js';
|
|
19
|
+
|
|
20
|
+
export interface TaskManagerConfig {
|
|
21
|
+
/** Time-to-live for completed task results (milliseconds). */
|
|
22
|
+
defaultTtl: number;
|
|
23
|
+
/** Suggested interval between client polls (milliseconds). */
|
|
24
|
+
pollInterval: number;
|
|
25
|
+
/** Maximum messages per task queue (prevents unbounded growth). */
|
|
26
|
+
maxQueueSize: number;
|
|
27
|
+
/** Interval for cleanup/monitoring sweep (milliseconds). */
|
|
28
|
+
cleanupInterval: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_CONFIG: TaskManagerConfig = {
|
|
32
|
+
defaultTtl: 300_000, // 5 minutes
|
|
33
|
+
pollInterval: 2_000, // 2 seconds
|
|
34
|
+
maxQueueSize: 100,
|
|
35
|
+
cleanupInterval: 60_000, // 1 minute
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Manages MCP task lifecycle: creation, background execution, cancellation, and cleanup.
|
|
40
|
+
*
|
|
41
|
+
* - Provides `InMemoryTaskStore` and `InMemoryTaskMessageQueue` instances for
|
|
42
|
+
* `McpServer`'s `ProtocolOptions`.
|
|
43
|
+
* - Tracks active background work via `AbortController` per task for cancellation.
|
|
44
|
+
* - Handles graceful shutdown (cancels active tasks, clears timers).
|
|
45
|
+
*/
|
|
46
|
+
export class TaskManager {
|
|
47
|
+
readonly taskStore: TaskStore;
|
|
48
|
+
readonly taskMessageQueue: TaskMessageQueue;
|
|
49
|
+
readonly config: TaskManagerConfig;
|
|
50
|
+
|
|
51
|
+
/** Maps taskId → AbortController for active background work. */
|
|
52
|
+
private activeControllers: Map<string, AbortController> = new Map();
|
|
53
|
+
private cleanupTimer?: ReturnType<typeof setInterval>;
|
|
54
|
+
|
|
55
|
+
constructor(config?: Partial<TaskManagerConfig>) {
|
|
56
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
57
|
+
this.taskStore = new InMemoryTaskStore();
|
|
58
|
+
this.taskMessageQueue = new InMemoryTaskMessageQueue();
|
|
59
|
+
this.startCleanup();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Start background work for a task.
|
|
64
|
+
*
|
|
65
|
+
* This is fire-and-forget: the returned promise resolves immediately.
|
|
66
|
+
* The `work` function runs asynchronously; its result is stored in the
|
|
67
|
+
* task store on completion. On error the task is marked `failed`.
|
|
68
|
+
* On abort (cancellation) the task is marked `cancelled`.
|
|
69
|
+
*
|
|
70
|
+
* @param taskId ID of the task (from `taskStore.createTask`)
|
|
71
|
+
* @param work Async function receiving an `AbortSignal` and returning a `CallToolResult`
|
|
72
|
+
*/
|
|
73
|
+
startBackground(
|
|
74
|
+
taskId: string,
|
|
75
|
+
work: (signal: AbortSignal) => Promise<CallToolResult>
|
|
76
|
+
): void {
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
this.activeControllers.set(taskId, controller);
|
|
79
|
+
|
|
80
|
+
void (async () => {
|
|
81
|
+
try {
|
|
82
|
+
await this.taskStore.updateTaskStatus(taskId, 'working');
|
|
83
|
+
const result = await work(controller.signal);
|
|
84
|
+
|
|
85
|
+
if (!controller.signal.aborted) {
|
|
86
|
+
await this.taskStore.storeTaskResult(taskId, 'completed', result as Result);
|
|
87
|
+
} else {
|
|
88
|
+
// Work completed but cancellation was requested mid-execution.
|
|
89
|
+
// Mark as cancelled so the task doesn't stay stuck in 'working'.
|
|
90
|
+
try {
|
|
91
|
+
await this.taskStore.updateTaskStatus(taskId, 'cancelled', 'Task was cancelled');
|
|
92
|
+
} catch {
|
|
93
|
+
// Task may already be in a terminal state
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (controller.signal.aborted) {
|
|
98
|
+
try {
|
|
99
|
+
await this.taskStore.updateTaskStatus(taskId, 'cancelled', 'Task was cancelled');
|
|
100
|
+
} catch {
|
|
101
|
+
// Task may already be in a terminal state
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
+
logger.error(`Task ${taskId} failed:`, message);
|
|
106
|
+
try {
|
|
107
|
+
await this.taskStore.storeTaskResult(taskId, 'failed', {
|
|
108
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
109
|
+
isError: true,
|
|
110
|
+
} as Result);
|
|
111
|
+
} catch {
|
|
112
|
+
// Task store may have already cleaned up (TTL)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
this.activeControllers.delete(taskId);
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Cancel a running task by aborting its AbortController. */
|
|
122
|
+
cancel(taskId: string): boolean {
|
|
123
|
+
const controller = this.activeControllers.get(taskId);
|
|
124
|
+
if (controller) {
|
|
125
|
+
controller.abort();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Number of currently active background tasks. */
|
|
132
|
+
get activeCount(): number {
|
|
133
|
+
return this.activeControllers.size;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private startCleanup(): void {
|
|
137
|
+
this.cleanupTimer = setInterval(() => {
|
|
138
|
+
logger.debug(`Active background tasks: ${this.activeControllers.size}`);
|
|
139
|
+
}, this.config.cleanupInterval);
|
|
140
|
+
// Allow the process to exit even if the timer is still running
|
|
141
|
+
if (this.cleanupTimer && typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {
|
|
142
|
+
this.cleanupTimer.unref();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Graceful shutdown: cancel all active tasks and clear timers. */
|
|
147
|
+
shutdown(): void {
|
|
148
|
+
if (this.cleanupTimer) {
|
|
149
|
+
clearInterval(this.cleanupTimer);
|
|
150
|
+
this.cleanupTimer = undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const [taskId, controller] of this.activeControllers) {
|
|
154
|
+
logger.info(`Cancelling active task ${taskId} during shutdown`);
|
|
155
|
+
controller.abort();
|
|
156
|
+
}
|
|
157
|
+
this.activeControllers.clear();
|
|
158
|
+
|
|
159
|
+
// Clear InMemoryTaskStore internal TTL timers
|
|
160
|
+
(this.taskStore as InMemoryTaskStore).cleanup();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ProviderManager } from '../providers/manager.js';
|
|
2
2
|
import { duckArt } from '../utils/ascii-art.js';
|
|
3
3
|
import { logger } from '../utils/logger.js';
|
|
4
|
+
import type { ProgressReporter } from '../services/progress.js';
|
|
4
5
|
|
|
5
6
|
export async function compareDucksTool(
|
|
6
7
|
providerManager: ProviderManager,
|
|
7
|
-
args: Record<string, unknown
|
|
8
|
+
args: Record<string, unknown>,
|
|
9
|
+
progress?: ProgressReporter
|
|
8
10
|
) {
|
|
9
11
|
const { prompt, providers, model } = args as {
|
|
10
12
|
prompt?: string;
|
|
@@ -16,8 +18,17 @@ export async function compareDucksTool(
|
|
|
16
18
|
throw new Error('Prompt is required');
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
// Get responses from multiple ducks
|
|
20
|
-
const responses =
|
|
21
|
+
// Get responses from multiple ducks, reporting progress as each completes
|
|
22
|
+
const responses = progress
|
|
23
|
+
? await providerManager.compareDucksWithProgress(
|
|
24
|
+
prompt,
|
|
25
|
+
providers,
|
|
26
|
+
{ model },
|
|
27
|
+
(providerName, completed, total) => {
|
|
28
|
+
void progress.report(completed, total, `${providerName} responded (${completed}/${total})`);
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
: await providerManager.compareDucks(prompt, providers, { model });
|
|
21
32
|
|
|
22
33
|
// Build comparison response
|
|
23
34
|
let response = `${duckArt.panel}\n`;
|
|
@@ -55,12 +66,32 @@ export async function compareDucksTool(
|
|
|
55
66
|
|
|
56
67
|
logger.info(`Compared ${responses.length} ducks, ${successCount} successful`);
|
|
57
68
|
|
|
69
|
+
// Build structured data for UI consumption
|
|
70
|
+
const structuredData = responses.map(r => ({
|
|
71
|
+
provider: r.provider,
|
|
72
|
+
nickname: r.nickname,
|
|
73
|
+
model: r.model,
|
|
74
|
+
content: r.content,
|
|
75
|
+
latency: r.latency,
|
|
76
|
+
tokens: r.usage ? {
|
|
77
|
+
prompt: r.usage.prompt_tokens,
|
|
78
|
+
completion: r.usage.completion_tokens,
|
|
79
|
+
total: r.usage.total_tokens,
|
|
80
|
+
} : null,
|
|
81
|
+
cached: r.cached,
|
|
82
|
+
error: r.content.startsWith('Error:') ? r.content : undefined,
|
|
83
|
+
}));
|
|
84
|
+
|
|
58
85
|
return {
|
|
59
86
|
content: [
|
|
60
87
|
{
|
|
61
88
|
type: 'text',
|
|
62
89
|
text: response,
|
|
63
90
|
},
|
|
91
|
+
{
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: JSON.stringify(structuredData),
|
|
94
|
+
},
|
|
64
95
|
],
|
|
65
96
|
};
|
|
66
97
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ProviderManager } from '../providers/manager.js';
|
|
2
2
|
import { duckArt, getRandomDuckMessage } from '../utils/ascii-art.js';
|
|
3
3
|
import { logger } from '../utils/logger.js';
|
|
4
|
+
import type { ProgressReporter } from '../services/progress.js';
|
|
4
5
|
|
|
5
6
|
export async function duckCouncilTool(
|
|
6
7
|
providerManager: ProviderManager,
|
|
7
|
-
args: Record<string, unknown
|
|
8
|
+
args: Record<string, unknown>,
|
|
9
|
+
progress?: ProgressReporter
|
|
8
10
|
) {
|
|
9
11
|
const { prompt, model } = args as {
|
|
10
12
|
prompt?: string;
|
|
@@ -19,13 +21,22 @@ export async function duckCouncilTool(
|
|
|
19
21
|
|
|
20
22
|
// Get all available ducks
|
|
21
23
|
const allProviders = providerManager.getProviderNames();
|
|
22
|
-
|
|
24
|
+
|
|
23
25
|
if (allProviders.length === 0) {
|
|
24
26
|
throw new Error('No ducks available for the council!');
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
// Get responses from all ducks
|
|
28
|
-
const responses =
|
|
29
|
+
// Get responses from all ducks, reporting progress as each completes
|
|
30
|
+
const responses = progress
|
|
31
|
+
? await providerManager.compareDucksWithProgress(
|
|
32
|
+
prompt,
|
|
33
|
+
undefined,
|
|
34
|
+
{ model },
|
|
35
|
+
(providerName, completed, total) => {
|
|
36
|
+
void progress.report(completed, total, `${providerName} responded (${completed}/${total})`);
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
: await providerManager.duckCouncil(prompt, { model });
|
|
29
40
|
|
|
30
41
|
// Build council response with a panel discussion format
|
|
31
42
|
let response = `${duckArt.panel}\n\n`;
|
package/src/tools/duck-debate.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
DebateResult,
|
|
8
8
|
} from '../config/types.js';
|
|
9
9
|
import { logger } from '../utils/logger.js';
|
|
10
|
+
import type { ProgressReporter } from '../services/progress.js';
|
|
10
11
|
|
|
11
12
|
export interface DuckDebateArgs {
|
|
12
13
|
prompt: string;
|
|
@@ -20,7 +21,9 @@ const DEFAULT_ROUNDS = 3;
|
|
|
20
21
|
|
|
21
22
|
export async function duckDebateTool(
|
|
22
23
|
providerManager: ProviderManager,
|
|
23
|
-
args: Record<string, unknown
|
|
24
|
+
args: Record<string, unknown>,
|
|
25
|
+
progress?: ProgressReporter,
|
|
26
|
+
signal?: AbortSignal
|
|
24
27
|
) {
|
|
25
28
|
const {
|
|
26
29
|
prompt,
|
|
@@ -73,13 +76,23 @@ export async function duckDebateTool(
|
|
|
73
76
|
|
|
74
77
|
// Run debate rounds
|
|
75
78
|
const debateRounds: DebateArgument[][] = [];
|
|
79
|
+
const totalSteps = rounds * participants.length + 1; // +1 for synthesis
|
|
80
|
+
let completedSteps = 0;
|
|
76
81
|
|
|
77
82
|
for (let roundNum = 1; roundNum <= rounds; roundNum++) {
|
|
83
|
+
if (signal?.aborted) {
|
|
84
|
+
throw new Error('Task cancelled');
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
logger.info(`Debate round ${roundNum}/${rounds}`);
|
|
79
88
|
const roundArguments: DebateArgument[] = [];
|
|
80
89
|
|
|
81
90
|
// Each participant argues in this round
|
|
82
91
|
for (const participant of participants) {
|
|
92
|
+
if (signal?.aborted) {
|
|
93
|
+
throw new Error('Task cancelled');
|
|
94
|
+
}
|
|
95
|
+
|
|
83
96
|
const argumentPrompt = buildArgumentPrompt(
|
|
84
97
|
prompt,
|
|
85
98
|
format,
|
|
@@ -99,16 +112,33 @@ export async function duckDebateTool(
|
|
|
99
112
|
content: response.content,
|
|
100
113
|
timestamp: new Date(),
|
|
101
114
|
});
|
|
115
|
+
|
|
116
|
+
completedSteps++;
|
|
117
|
+
if (progress) {
|
|
118
|
+
void progress.report(
|
|
119
|
+
completedSteps,
|
|
120
|
+
totalSteps,
|
|
121
|
+
`Round ${roundNum}/${rounds}: ${participant.nickname} (${participant.position})`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
debateRounds.push(roundArguments);
|
|
105
127
|
}
|
|
106
128
|
|
|
129
|
+
if (signal?.aborted) {
|
|
130
|
+
throw new Error('Task cancelled');
|
|
131
|
+
}
|
|
132
|
+
|
|
107
133
|
// Generate synthesis
|
|
108
134
|
const synthesizerProvider = synthesizer || debateProviders[0];
|
|
109
135
|
const synthesisPrompt = buildSynthesisPrompt(prompt, format, debateRounds, participants);
|
|
110
136
|
const synthesisResponse = await providerManager.askDuck(synthesizerProvider, synthesisPrompt);
|
|
111
137
|
|
|
138
|
+
if (progress) {
|
|
139
|
+
void progress.report(totalSteps, totalSteps, 'Synthesis complete');
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
const result: DebateResult = {
|
|
113
143
|
topic: prompt,
|
|
114
144
|
format,
|
|
@@ -124,12 +154,39 @@ export async function duckDebateTool(
|
|
|
124
154
|
|
|
125
155
|
logger.info(`Debate completed: ${rounds} rounds, synthesized by ${synthesizerProvider}`);
|
|
126
156
|
|
|
157
|
+
// Build structured data for UI consumption
|
|
158
|
+
const structuredData = {
|
|
159
|
+
topic: result.topic,
|
|
160
|
+
format: result.format,
|
|
161
|
+
totalRounds: result.totalRounds,
|
|
162
|
+
participants: result.participants.map(p => ({
|
|
163
|
+
provider: p.provider,
|
|
164
|
+
nickname: p.nickname,
|
|
165
|
+
position: p.position,
|
|
166
|
+
})),
|
|
167
|
+
rounds: result.rounds.map(roundArgs =>
|
|
168
|
+
roundArgs.map(arg => ({
|
|
169
|
+
round: arg.round,
|
|
170
|
+
provider: arg.provider,
|
|
171
|
+
nickname: arg.nickname,
|
|
172
|
+
position: arg.position,
|
|
173
|
+
content: arg.content,
|
|
174
|
+
}))
|
|
175
|
+
),
|
|
176
|
+
synthesis: result.synthesis,
|
|
177
|
+
synthesizer: result.synthesizer,
|
|
178
|
+
};
|
|
179
|
+
|
|
127
180
|
return {
|
|
128
181
|
content: [
|
|
129
182
|
{
|
|
130
183
|
type: 'text',
|
|
131
184
|
text: formattedOutput,
|
|
132
185
|
},
|
|
186
|
+
{
|
|
187
|
+
type: 'text',
|
|
188
|
+
text: JSON.stringify(structuredData),
|
|
189
|
+
},
|
|
133
190
|
],
|
|
134
191
|
};
|
|
135
192
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ProviderManager } from '../providers/manager.js';
|
|
2
2
|
import { IterationRound, IterationResult } from '../config/types.js';
|
|
3
3
|
import { logger } from '../utils/logger.js';
|
|
4
|
+
import type { ProgressReporter } from '../services/progress.js';
|
|
4
5
|
|
|
5
6
|
export interface DuckIterateArgs {
|
|
6
7
|
prompt: string;
|
|
@@ -14,7 +15,9 @@ const CONVERGENCE_THRESHOLD = 0.8; // 80% similarity indicates convergence
|
|
|
14
15
|
|
|
15
16
|
export async function duckIterateTool(
|
|
16
17
|
providerManager: ProviderManager,
|
|
17
|
-
args: Record<string, unknown
|
|
18
|
+
args: Record<string, unknown>,
|
|
19
|
+
progress?: ProgressReporter,
|
|
20
|
+
signal?: AbortSignal
|
|
18
21
|
) {
|
|
19
22
|
const {
|
|
20
23
|
prompt,
|
|
@@ -54,6 +57,10 @@ export async function duckIterateTool(
|
|
|
54
57
|
let lastResponse = '';
|
|
55
58
|
let converged = false;
|
|
56
59
|
|
|
60
|
+
if (signal?.aborted) {
|
|
61
|
+
throw new Error('Task cancelled');
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
// Round 1: Initial generation by provider A
|
|
58
65
|
const initialResponse = await providerManager.askDuck(providers[0], prompt);
|
|
59
66
|
const providerAInfo = providerManager.getProvider(providers[0]);
|
|
@@ -70,8 +77,16 @@ export async function duckIterateTool(
|
|
|
70
77
|
lastResponse = initialResponse.content;
|
|
71
78
|
logger.info(`Round 1: ${providers[0]} generated initial response`);
|
|
72
79
|
|
|
80
|
+
if (progress) {
|
|
81
|
+
void progress.report(1, iterations, `Round 1/${iterations}: ${providers[0]} generated`);
|
|
82
|
+
}
|
|
83
|
+
|
|
73
84
|
// Subsequent rounds: Alternate between providers
|
|
74
85
|
for (let i = 2; i <= iterations; i++) {
|
|
86
|
+
if (signal?.aborted) {
|
|
87
|
+
throw new Error('Task cancelled');
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
const isProviderA = i % 2 === 1;
|
|
76
91
|
const currentProvider = isProviderA ? providers[0] : providers[1];
|
|
77
92
|
const providerInfo = providerManager.getProvider(currentProvider);
|
|
@@ -100,6 +115,10 @@ export async function duckIterateTool(
|
|
|
100
115
|
lastResponse = response.content;
|
|
101
116
|
logger.info(`Round ${i}: ${currentProvider} ${role === 'critic' ? 'critiqued' : 'refined'}`);
|
|
102
117
|
|
|
118
|
+
if (progress) {
|
|
119
|
+
void progress.report(i, iterations, `Round ${i}/${iterations}: ${currentProvider} ${role}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
103
122
|
if (converged) {
|
|
104
123
|
break;
|
|
105
124
|
}
|
package/src/tools/duck-vote.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ProviderManager } from '../providers/manager.js';
|
|
|
2
2
|
import { ConsensusService } from '../services/consensus.js';
|
|
3
3
|
import { VoteResult } from '../config/types.js';
|
|
4
4
|
import { logger } from '../utils/logger.js';
|
|
5
|
+
import type { ProgressReporter } from '../services/progress.js';
|
|
5
6
|
|
|
6
7
|
export interface DuckVoteArgs {
|
|
7
8
|
question: string;
|
|
@@ -12,7 +13,8 @@ export interface DuckVoteArgs {
|
|
|
12
13
|
|
|
13
14
|
export async function duckVoteTool(
|
|
14
15
|
providerManager: ProviderManager,
|
|
15
|
-
args: Record<string, unknown
|
|
16
|
+
args: Record<string, unknown>,
|
|
17
|
+
progress?: ProgressReporter
|
|
16
18
|
) {
|
|
17
19
|
const {
|
|
18
20
|
question,
|
|
@@ -52,8 +54,17 @@ export async function duckVoteTool(
|
|
|
52
54
|
require_reasoning
|
|
53
55
|
);
|
|
54
56
|
|
|
55
|
-
// Get votes from all ducks in parallel
|
|
56
|
-
const responses =
|
|
57
|
+
// Get votes from all ducks in parallel, reporting progress as each votes
|
|
58
|
+
const responses = progress
|
|
59
|
+
? await providerManager.compareDucksWithProgress(
|
|
60
|
+
votePrompt,
|
|
61
|
+
voterNames,
|
|
62
|
+
undefined,
|
|
63
|
+
(providerName, completed, total) => {
|
|
64
|
+
void progress.report(completed, total, `${providerName} voted (${completed}/${total})`);
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
: await providerManager.compareDucks(votePrompt, voterNames);
|
|
57
68
|
|
|
58
69
|
// Parse votes
|
|
59
70
|
const votes: VoteResult[] = responses.map(response => {
|
|
@@ -76,12 +87,36 @@ export async function duckVoteTool(
|
|
|
76
87
|
`winner: ${aggregatedResult.winner || 'none'}`
|
|
77
88
|
);
|
|
78
89
|
|
|
90
|
+
// Build structured data for UI consumption
|
|
91
|
+
const structuredData = {
|
|
92
|
+
question: aggregatedResult.question,
|
|
93
|
+
options: aggregatedResult.options,
|
|
94
|
+
winner: aggregatedResult.winner,
|
|
95
|
+
isTie: aggregatedResult.isTie,
|
|
96
|
+
tally: aggregatedResult.tally,
|
|
97
|
+
confidenceByOption: aggregatedResult.confidenceByOption,
|
|
98
|
+
votes: aggregatedResult.votes.map(v => ({
|
|
99
|
+
voter: v.voter,
|
|
100
|
+
nickname: v.nickname,
|
|
101
|
+
choice: v.choice,
|
|
102
|
+
confidence: v.confidence,
|
|
103
|
+
reasoning: v.reasoning,
|
|
104
|
+
})),
|
|
105
|
+
totalVoters: aggregatedResult.totalVoters,
|
|
106
|
+
validVotes: aggregatedResult.validVotes,
|
|
107
|
+
consensusLevel: aggregatedResult.consensusLevel,
|
|
108
|
+
};
|
|
109
|
+
|
|
79
110
|
return {
|
|
80
111
|
content: [
|
|
81
112
|
{
|
|
82
113
|
type: 'text',
|
|
83
114
|
text: formattedOutput,
|
|
84
115
|
},
|
|
116
|
+
{
|
|
117
|
+
type: 'text',
|
|
118
|
+
text: JSON.stringify(structuredData),
|
|
119
|
+
},
|
|
85
120
|
],
|
|
86
121
|
};
|
|
87
122
|
}
|
|
@@ -75,12 +75,26 @@ export function getUsageStatsTool(
|
|
|
75
75
|
|
|
76
76
|
logger.info(`Retrieved usage stats for period: ${period}`);
|
|
77
77
|
|
|
78
|
+
// Build structured data for UI consumption
|
|
79
|
+
const structuredData = {
|
|
80
|
+
period: stats.period,
|
|
81
|
+
startDate: stats.startDate,
|
|
82
|
+
endDate: stats.endDate,
|
|
83
|
+
totals: stats.totals,
|
|
84
|
+
usage: stats.usage,
|
|
85
|
+
costByProvider: stats.costByProvider,
|
|
86
|
+
};
|
|
87
|
+
|
|
78
88
|
return {
|
|
79
89
|
content: [
|
|
80
90
|
{
|
|
81
91
|
type: 'text',
|
|
82
92
|
text: output,
|
|
83
93
|
},
|
|
94
|
+
{
|
|
95
|
+
type: 'text',
|
|
96
|
+
text: JSON.stringify(structuredData),
|
|
97
|
+
},
|
|
84
98
|
],
|
|
85
99
|
};
|
|
86
100
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { App } from '@modelcontextprotocol/ext-apps';
|
|
2
|
+
|
|
3
|
+
interface CompareResponse {
|
|
4
|
+
provider: string;
|
|
5
|
+
nickname: string;
|
|
6
|
+
model: string;
|
|
7
|
+
content: string;
|
|
8
|
+
latency: number;
|
|
9
|
+
tokens: { prompt: number; completion: number; total: number } | null;
|
|
10
|
+
cached: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const app = new App({ name: 'CompareDucks', version: '1.0.0' }, {});
|
|
15
|
+
|
|
16
|
+
app.ontoolresult = (params) => {
|
|
17
|
+
const container = document.getElementById('app')!;
|
|
18
|
+
if (params.isError) {
|
|
19
|
+
container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Parse JSON from second content item
|
|
24
|
+
const content = params.content;
|
|
25
|
+
if (!content || !Array.isArray(content) || content.length < 2) {
|
|
26
|
+
container.innerHTML = `<div class="error-banner">No structured data received</div>`;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const data: CompareResponse[] = JSON.parse(
|
|
32
|
+
(content[1] as { type: string; text: string }).text
|
|
33
|
+
);
|
|
34
|
+
render(data);
|
|
35
|
+
} catch {
|
|
36
|
+
container.innerHTML = `<div class="error-banner">Failed to parse response data</div>`;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function render(responses: CompareResponse[]) {
|
|
41
|
+
const container = document.getElementById('app')!;
|
|
42
|
+
const successCount = responses.filter((r) => !r.error).length;
|
|
43
|
+
|
|
44
|
+
let html = `<div class="summary-bar">${successCount}/${responses.length} ducks responded successfully</div>`;
|
|
45
|
+
html += `<div class="grid">`;
|
|
46
|
+
|
|
47
|
+
for (const r of responses) {
|
|
48
|
+
const isError = !!r.error;
|
|
49
|
+
const latencyClass =
|
|
50
|
+
r.latency < 2000 ? 'fast' : r.latency < 5000 ? 'medium' : 'slow';
|
|
51
|
+
|
|
52
|
+
html += `<div class="card${isError ? ' card-error' : ''}">`;
|
|
53
|
+
html += `<div class="card-header">`;
|
|
54
|
+
html += `<span class="nickname">${esc(r.nickname)}</span>`;
|
|
55
|
+
html += `<span class="provider">${esc(r.provider)}</span>`;
|
|
56
|
+
html += `</div>`;
|
|
57
|
+
|
|
58
|
+
if (!isError) {
|
|
59
|
+
html += `<div class="badges">`;
|
|
60
|
+
html += `<span class="badge model">${esc(r.model)}</span>`;
|
|
61
|
+
if (r.tokens) {
|
|
62
|
+
html += `<span class="badge tokens">${r.tokens.total} tokens</span>`;
|
|
63
|
+
}
|
|
64
|
+
if (r.cached) {
|
|
65
|
+
html += `<span class="badge cached">Cached</span>`;
|
|
66
|
+
}
|
|
67
|
+
html += `</div>`;
|
|
68
|
+
html += `<div class="latency-bar ${latencyClass}" style="width:${Math.min(100, (r.latency / 10000) * 100)}%"></div>`;
|
|
69
|
+
html += `<div class="latency-label">${r.latency}ms</div>`;
|
|
70
|
+
html += `<div class="content"><pre>${esc(r.content)}</pre></div>`;
|
|
71
|
+
} else {
|
|
72
|
+
html += `<div class="content error-text"><pre>${esc(r.content)}</pre></div>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
html += `</div>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
html += `</div>`;
|
|
79
|
+
container.innerHTML = html;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function esc(s: string): string {
|
|
83
|
+
const d = document.createElement('div');
|
|
84
|
+
d.textContent = s;
|
|
85
|
+
return d.innerHTML;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
app.connect().catch(console.error);
|