iflow-mcp-deeflect-smart-spawn 0.1.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,183 @@
1
+ import type { Budget, RoleConfig } from "./types.ts";
2
+
3
+ interface HttpOptions {
4
+ method?: string;
5
+ body?: unknown;
6
+ }
7
+
8
+ export class SmartSpawnClient {
9
+ constructor(private readonly baseUrl: string) {}
10
+
11
+ async pick(params: {
12
+ task: string;
13
+ budget?: Budget;
14
+ context?: string;
15
+ exclude?: string[];
16
+ }): Promise<{ modelId: string; reason: string }> {
17
+ const query = new URLSearchParams({
18
+ task: params.task,
19
+ budget: params.budget ?? "medium",
20
+ });
21
+ if (params.context) query.set("context", params.context);
22
+ if (params.exclude?.length) query.set("exclude", params.exclude.join(","));
23
+
24
+ const data = await this.getJson(`/pick?${query.toString()}`);
25
+ const modelId = data?.data?.id as string | undefined;
26
+ if (!modelId) throw new Error("Smart Spawn /pick did not return data.id");
27
+ return { modelId, reason: String(data?.data?.reason ?? "Picked by /pick") };
28
+ }
29
+
30
+ async recommend(params: {
31
+ task: string;
32
+ budget?: Budget;
33
+ count?: number;
34
+ context?: string;
35
+ exclude?: string[];
36
+ }): Promise<Array<{ modelId: string; reason: string }>> {
37
+ const query = new URLSearchParams({
38
+ task: params.task,
39
+ budget: params.budget ?? "medium",
40
+ count: String(params.count ?? 3),
41
+ });
42
+ if (params.context) query.set("context", params.context);
43
+ if (params.exclude?.length) query.set("exclude", params.exclude.join(","));
44
+
45
+ const data = await this.getJson(`/recommend?${query.toString()}`);
46
+ const items = Array.isArray(data?.data) ? data.data : [];
47
+ return items
48
+ .map((item: any) => ({
49
+ modelId: item?.model?.id as string | undefined,
50
+ reason: String(item?.reason ?? "Recommended by /recommend"),
51
+ }))
52
+ .filter((item: { modelId: string | undefined }) => Boolean(item.modelId)) as Array<{ modelId: string; reason: string }>;
53
+ }
54
+
55
+ async decompose(params: {
56
+ task: string;
57
+ budget?: Budget;
58
+ context?: string;
59
+ }): Promise<{
60
+ decomposed: boolean;
61
+ steps: Array<{ id: string; task: string; modelId: string; wave: number; dependsOn: string[]; reason: string }>;
62
+ }> {
63
+ const data = await this.postJson("/decompose", {
64
+ task: params.task,
65
+ budget: params.budget ?? "medium",
66
+ context: params.context,
67
+ });
68
+
69
+ if (!data?.decomposed) return { decomposed: false, steps: [] };
70
+ const steps = Array.isArray(data?.steps) ? data.steps : [];
71
+ return {
72
+ decomposed: true,
73
+ steps: steps
74
+ .map((step: any) => ({
75
+ id: `step-${step.step}`,
76
+ task: String(step.task ?? ""),
77
+ modelId: step?.model?.id as string | undefined,
78
+ wave: Number(step.step ?? 1) - 1,
79
+ dependsOn: Number(step.step ?? 1) > 1 ? [`step-${Number(step.step ?? 1) - 1}`] : [],
80
+ reason: String(step.reason ?? "Planned by /decompose"),
81
+ }))
82
+ .filter((s: { modelId: string | undefined }) => Boolean(s.modelId)) as Array<{
83
+ id: string;
84
+ task: string;
85
+ modelId: string;
86
+ wave: number;
87
+ dependsOn: string[];
88
+ reason: string;
89
+ }>,
90
+ };
91
+ }
92
+
93
+ async swarm(params: {
94
+ task: string;
95
+ budget?: Budget;
96
+ context?: string;
97
+ maxParallel?: number;
98
+ }): Promise<{
99
+ decomposed: boolean;
100
+ tasks: Array<{ id: string; task: string; modelId: string; wave: number; dependsOn: string[]; reason: string }>;
101
+ }> {
102
+ const data = await this.postJson("/swarm", {
103
+ task: params.task,
104
+ budget: params.budget ?? "medium",
105
+ context: params.context,
106
+ maxParallel: params.maxParallel ?? 5,
107
+ });
108
+
109
+ if (!data?.decomposed || !data?.dag) return { decomposed: false, tasks: [] };
110
+ const tasks = Array.isArray(data?.dag?.tasks) ? data.dag.tasks : [];
111
+ return {
112
+ decomposed: true,
113
+ tasks: tasks
114
+ .map((task: any) => ({
115
+ id: String(task.id ?? ""),
116
+ task: String(task.description ?? task.task ?? ""),
117
+ modelId: task?.model?.id as string | undefined,
118
+ wave: Number(task.wave ?? 0),
119
+ dependsOn: Array.isArray(task.dependsOn) ? task.dependsOn.map((d: unknown) => String(d)) : [],
120
+ reason: String(task.reason ?? "Planned by /swarm"),
121
+ }))
122
+ .filter((t: { modelId: string | undefined }) => Boolean(t.modelId)) as Array<{
123
+ id: string;
124
+ task: string;
125
+ modelId: string;
126
+ wave: number;
127
+ dependsOn: string[];
128
+ reason: string;
129
+ }>,
130
+ };
131
+ }
132
+
133
+ async composeRole(task: string, role?: RoleConfig): Promise<string> {
134
+ if (!role) return task;
135
+ const data = await this.postJson("/roles/compose", {
136
+ task,
137
+ ...role,
138
+ });
139
+ const prompt = data?.fullPrompt as string | undefined;
140
+ return prompt && prompt.trim().length > 0 ? prompt : task;
141
+ }
142
+
143
+ async health(): Promise<{ reachable: boolean; payload: unknown | null }> {
144
+ try {
145
+ const data = await this.getJson("/status");
146
+ return { reachable: true, payload: data };
147
+ } catch {
148
+ return { reachable: false, payload: null };
149
+ }
150
+ }
151
+
152
+ private async getJson(path: string): Promise<any> {
153
+ return this.request(path, { method: "GET" });
154
+ }
155
+
156
+ private async postJson(path: string, body: unknown): Promise<any> {
157
+ return this.request(path, { method: "POST", body });
158
+ }
159
+
160
+ private async request(path: string, options: HttpOptions): Promise<any> {
161
+ const url = `${this.baseUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
162
+ const res = await fetch(url, {
163
+ method: options.method ?? "GET",
164
+ headers: {
165
+ "Content-Type": "application/json",
166
+ },
167
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
168
+ });
169
+
170
+ const raw = await res.text();
171
+ const data = raw ? JSON.parse(raw) : null;
172
+
173
+ if (!res.ok) {
174
+ const message =
175
+ data?.error?.message ??
176
+ data?.message ??
177
+ `${options.method ?? "GET"} ${path} failed with status ${res.status}`;
178
+ throw new Error(String(message));
179
+ }
180
+
181
+ return data;
182
+ }
183
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export class ArtifactStorage {
6
+ constructor(
7
+ public readonly homeDir: string,
8
+ public readonly artifactsDir: string
9
+ ) {}
10
+
11
+ async ensure(): Promise<void> {
12
+ await mkdir(this.homeDir, { recursive: true });
13
+ await mkdir(this.artifactsDir, { recursive: true });
14
+ }
15
+
16
+ async writeArtifact(
17
+ runId: string,
18
+ nodeId: string,
19
+ type: "raw" | "merged" | "plan" | "log",
20
+ content: string,
21
+ extension: "json" | "md" | "txt" = "json"
22
+ ): Promise<{ relativePath: string; bytes: number; sha256: string }> {
23
+ const relativePath = join("artifacts", runId, `${nodeId}.${extension}`);
24
+ const absolutePath = join(this.homeDir, relativePath);
25
+
26
+ await mkdir(dirname(absolutePath), { recursive: true });
27
+ await writeFile(absolutePath, content, "utf-8");
28
+
29
+ const bytes = Buffer.byteLength(content, "utf-8");
30
+ const sha256 = createHash("sha256").update(content).digest("hex");
31
+
32
+ // `type` is part of the function signature intentionally for schema clarity.
33
+ void type;
34
+
35
+ return { relativePath, bytes, sha256 };
36
+ }
37
+
38
+ async readArtifact(relativePath: string): Promise<string> {
39
+ const absolutePath = join(this.homeDir, relativePath);
40
+ const data = await readFile(absolutePath, "utf-8");
41
+ return data;
42
+ }
43
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,273 @@
1
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { RuntimeQueue } from "./runtime/queue.ts";
3
+ import type { RunCreateInput, RunStatus } from "./types.ts";
4
+
5
+ const TOOL_DEFS = [
6
+ {
7
+ name: "smartspawn_run_create",
8
+ description: "Create an async Smart Spawn run. This orchestrates sub-agents and returns a run_id immediately.",
9
+ inputSchema: {
10
+ type: "object",
11
+ properties: {
12
+ task: { type: "string" },
13
+ mode: { type: "string", enum: ["single", "collective", "cascade", "plan", "swarm"] },
14
+ budget: { type: "string", enum: ["low", "medium", "high", "any"] },
15
+ context: { type: "string" },
16
+ collectiveCount: { type: "number" },
17
+ role: {
18
+ type: "object",
19
+ properties: {
20
+ persona: { type: "string" },
21
+ stack: { type: "array", items: { type: "string" } },
22
+ domain: { type: "string" },
23
+ format: { type: "string" },
24
+ guardrails: { type: "array", items: { type: "string" } },
25
+ },
26
+ additionalProperties: false,
27
+ },
28
+ merge: {
29
+ type: "object",
30
+ properties: {
31
+ style: { type: "string", enum: ["concise", "detailed", "decision"] },
32
+ model: { type: "string" },
33
+ },
34
+ additionalProperties: false,
35
+ },
36
+ },
37
+ required: ["task", "mode"],
38
+ additionalProperties: false,
39
+ },
40
+ },
41
+ {
42
+ name: "smartspawn_run_status",
43
+ description: "Get status/progress for an async run.",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ run_id: { type: "string" },
48
+ },
49
+ required: ["run_id"],
50
+ additionalProperties: false,
51
+ },
52
+ },
53
+ {
54
+ name: "smartspawn_run_result",
55
+ description: "Get merged result for a run (raw outputs optional).",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ run_id: { type: "string" },
60
+ include_raw: { type: "boolean" },
61
+ },
62
+ required: ["run_id"],
63
+ additionalProperties: false,
64
+ },
65
+ },
66
+ {
67
+ name: "smartspawn_run_cancel",
68
+ description: "Cancel a queued/running run.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ run_id: { type: "string" },
73
+ },
74
+ required: ["run_id"],
75
+ additionalProperties: false,
76
+ },
77
+ },
78
+ {
79
+ name: "smartspawn_run_list",
80
+ description: "List recent runs.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ status: { type: "string", enum: ["queued", "running", "completed", "failed", "canceled"] },
85
+ limit: { type: "number" },
86
+ },
87
+ additionalProperties: false,
88
+ },
89
+ },
90
+ {
91
+ name: "smartspawn_artifact_get",
92
+ description: "Read a stored artifact by run and node id (use node_id='merged' for final answer).",
93
+ inputSchema: {
94
+ type: "object",
95
+ properties: {
96
+ run_id: { type: "string" },
97
+ node_id: { type: "string" },
98
+ },
99
+ required: ["run_id", "node_id"],
100
+ additionalProperties: false,
101
+ },
102
+ },
103
+ {
104
+ name: "smartspawn_health",
105
+ description: "Health checks for OpenRouter config, Smart Spawn API, DB, storage, and worker.",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: {},
109
+ additionalProperties: false,
110
+ },
111
+ },
112
+ ] as const;
113
+
114
+ function asToolContent(payload: unknown): { content: Array<{ type: "text"; text: string }> } {
115
+ return {
116
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
117
+ };
118
+ }
119
+
120
+ function toErrorContent(message: string) {
121
+ return {
122
+ content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
123
+ isError: true,
124
+ };
125
+ }
126
+
127
+ export function listToolNames(): string[] {
128
+ return TOOL_DEFS.map((tool) => tool.name);
129
+ }
130
+
131
+ export function registerToolHandlers(server: any, runtime: RuntimeQueue): void {
132
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
133
+ tools: TOOL_DEFS,
134
+ }));
135
+
136
+ server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
137
+ const name = request?.params?.name as string | undefined;
138
+ const args = (request?.params?.arguments ?? {}) as Record<string, unknown>;
139
+
140
+ try {
141
+ if (name === "smartspawn_run_create") {
142
+ const task = String(args.task ?? "").trim();
143
+ const mode = String(args.mode ?? "").trim();
144
+ if (!task) return toErrorContent("task is required");
145
+ if (!["single", "collective", "cascade", "plan", "swarm"].includes(mode)) {
146
+ return toErrorContent("mode must be one of single|collective|cascade|plan|swarm");
147
+ }
148
+ const runInput: RunCreateInput = {
149
+ task,
150
+ mode: mode as RunCreateInput["mode"],
151
+ budget: args.budget as RunCreateInput["budget"],
152
+ context: typeof args.context === "string" ? args.context : undefined,
153
+ collectiveCount: typeof args.collectiveCount === "number" ? args.collectiveCount : undefined,
154
+ role: typeof args.role === "object" && args.role ? (args.role as RunCreateInput["role"]) : undefined,
155
+ merge: typeof args.merge === "object" && args.merge ? (args.merge as RunCreateInput["merge"]) : undefined,
156
+ };
157
+ const run = await runtime.createRun(runInput);
158
+ return asToolContent({
159
+ run_id: run.id,
160
+ status: run.status,
161
+ created_at: run.createdAt,
162
+ });
163
+ }
164
+
165
+ if (name === "smartspawn_run_status") {
166
+ const runId = String(args.run_id ?? "");
167
+ if (!runId) return toErrorContent("run_id is required");
168
+ const run = runtime.getRun(runId);
169
+ if (!run) return toErrorContent(`run not found: ${runId}`);
170
+ const progress = runtime.getProgress(runId);
171
+ return asToolContent({
172
+ run_id: runId,
173
+ status: run.status,
174
+ progress: {
175
+ total_nodes: progress.totalNodes,
176
+ done_nodes: progress.doneNodes,
177
+ running_nodes: progress.runningNodes,
178
+ failed_nodes: progress.failedNodes,
179
+ percent: progress.percent,
180
+ },
181
+ last_event: runtime.getLastEvent(runId),
182
+ updated_at: run.updatedAt,
183
+ });
184
+ }
185
+
186
+ if (name === "smartspawn_run_result") {
187
+ const runId = String(args.run_id ?? "");
188
+ if (!runId) return toErrorContent("run_id is required");
189
+ const includeRaw = Boolean(args.include_raw ?? false);
190
+ const result = await runtime.getResult(runId, includeRaw);
191
+ if (!result) return toErrorContent(`run not found: ${runId}`);
192
+ return asToolContent({
193
+ status: result.status,
194
+ merged_output: result.mergedOutput,
195
+ summary: result.summary,
196
+ artifacts: result.artifacts.map((a) => ({
197
+ node_id: a.nodeId,
198
+ path: a.path,
199
+ model: a.model,
200
+ status: a.status,
201
+ type: a.type,
202
+ })),
203
+ cost: {
204
+ prompt_tokens: result.cost.promptTokens,
205
+ completion_tokens: result.cost.completionTokens,
206
+ usd_estimate: result.cost.usdEstimate,
207
+ },
208
+ ...(includeRaw ? { raw_outputs: result.rawOutputs } : {}),
209
+ });
210
+ }
211
+
212
+ if (name === "smartspawn_run_cancel") {
213
+ const runId = String(args.run_id ?? "");
214
+ if (!runId) return toErrorContent("run_id is required");
215
+ const canceled = runtime.cancelRun(runId);
216
+ if (!canceled) return toErrorContent(`run not found: ${runId}`);
217
+ return asToolContent({
218
+ run_id: runId,
219
+ status: canceled.status,
220
+ });
221
+ }
222
+
223
+ if (name === "smartspawn_run_list") {
224
+ const status = (args.status ? String(args.status) : undefined) as RunStatus | undefined;
225
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
226
+ const runs = runtime.listRuns(status, limit);
227
+ return asToolContent({
228
+ runs: runs.map((run) => ({
229
+ run_id: run.id,
230
+ task: run.task,
231
+ status: run.status,
232
+ created_at: run.createdAt,
233
+ updated_at: run.updatedAt,
234
+ })),
235
+ });
236
+ }
237
+
238
+ if (name === "smartspawn_artifact_get") {
239
+ const runId = String(args.run_id ?? "");
240
+ const nodeId = String(args.node_id ?? "");
241
+ if (!runId || !nodeId) return toErrorContent("run_id and node_id are required");
242
+ const artifact = await runtime.getArtifact(runId, nodeId);
243
+ if (!artifact) return toErrorContent(`artifact not found for run=${runId} node=${nodeId}`);
244
+ return asToolContent({
245
+ artifact_type: artifact.type,
246
+ content: artifact.content,
247
+ metadata: {
248
+ bytes: artifact.metadata.bytes,
249
+ sha256: artifact.metadata.sha256,
250
+ created_at: artifact.metadata.createdAt,
251
+ path: artifact.metadata.path,
252
+ },
253
+ });
254
+ }
255
+
256
+ if (name === "smartspawn_health") {
257
+ const health = await runtime.health();
258
+ return asToolContent({
259
+ openrouter_configured: health.openrouterConfigured,
260
+ smart_spawn_api_reachable: health.smartSpawnApiReachable,
261
+ db_writable: health.dbWritable,
262
+ artifact_storage_writable: health.artifactStorageWritable,
263
+ worker_alive: health.workerAlive,
264
+ });
265
+ }
266
+
267
+ return toErrorContent(`unknown tool: ${name ?? "(missing name)"}`);
268
+ } catch (error) {
269
+ const message = error instanceof Error ? error.message : String(error);
270
+ return toErrorContent(message);
271
+ }
272
+ });
273
+ }
package/src/types.ts ADDED
@@ -0,0 +1,107 @@
1
+ export type RunMode = "single" | "collective" | "cascade" | "plan" | "swarm";
2
+ export type Budget = "low" | "medium" | "high" | "any";
3
+ export type RunStatus = "queued" | "running" | "completed" | "failed" | "canceled";
4
+ export type NodeStatus = "queued" | "running" | "completed" | "failed" | "canceled" | "skipped";
5
+ export type NodeKind = "task" | "merge";
6
+
7
+ export interface RoleConfig {
8
+ persona?: string;
9
+ stack?: string[];
10
+ domain?: string;
11
+ format?: string;
12
+ guardrails?: string[];
13
+ }
14
+
15
+ export interface MergeConfig {
16
+ style?: "concise" | "detailed" | "decision";
17
+ model?: string;
18
+ }
19
+
20
+ export interface RunCreateInput {
21
+ task: string;
22
+ mode: RunMode;
23
+ budget?: Budget;
24
+ context?: string;
25
+ collectiveCount?: number;
26
+ role?: RoleConfig;
27
+ merge?: MergeConfig;
28
+ }
29
+
30
+ export interface RunRecord {
31
+ id: string;
32
+ task: string;
33
+ mode: RunMode;
34
+ budget: Budget;
35
+ context: string | null;
36
+ paramsJson: string;
37
+ status: RunStatus;
38
+ error: string | null;
39
+ createdAt: string;
40
+ updatedAt: string;
41
+ startedAt: string | null;
42
+ finishedAt: string | null;
43
+ }
44
+
45
+ export interface NodeRecord {
46
+ id: string;
47
+ runId: string;
48
+ kind: NodeKind;
49
+ wave: number;
50
+ dependsOnJson: string;
51
+ task: string;
52
+ model: string;
53
+ prompt: string;
54
+ metaJson: string;
55
+ status: NodeStatus;
56
+ retryCount: number;
57
+ maxRetries: number;
58
+ error: string | null;
59
+ startedAt: string | null;
60
+ finishedAt: string | null;
61
+ tokensPrompt: number;
62
+ tokensCompletion: number;
63
+ costUsd: number;
64
+ }
65
+
66
+ export interface ArtifactRecord {
67
+ id: string;
68
+ runId: string;
69
+ nodeId: string;
70
+ type: "raw" | "merged" | "plan" | "log";
71
+ path: string;
72
+ bytes: number;
73
+ sha256: string;
74
+ createdAt: string;
75
+ }
76
+
77
+ export interface PlannedNode {
78
+ id: string;
79
+ kind: NodeKind;
80
+ wave: number;
81
+ dependsOn: string[];
82
+ task: string;
83
+ model: string;
84
+ prompt: string;
85
+ meta?: Record<string, unknown>;
86
+ maxRetries?: number;
87
+ }
88
+
89
+ export interface PlannedRun {
90
+ nodes: PlannedNode[];
91
+ plannerSummary: string;
92
+ }
93
+
94
+ export interface OpenRouterExecutionResult {
95
+ text: string;
96
+ promptTokens: number;
97
+ completionTokens: number;
98
+ totalTokens: number;
99
+ }
100
+
101
+ export interface RunProgress {
102
+ totalNodes: number;
103
+ doneNodes: number;
104
+ runningNodes: number;
105
+ failedNodes: number;
106
+ percent: number;
107
+ }
@@ -0,0 +1,7 @@
1
+ import { expect, test } from "bun:test";
2
+ import { buildOpenRouterHeaders } from "../src/openrouter-client.ts";
3
+
4
+ test("buildOpenRouterHeaders includes bearer token", () => {
5
+ const headers = buildOpenRouterHeaders("test-key");
6
+ expect(headers.Authorization).toBe("Bearer test-key");
7
+ });
@@ -0,0 +1,15 @@
1
+ import { expect, test } from "bun:test";
2
+ import { McpStore } from "../src/db.ts";
3
+
4
+ test("McpStore initializes core tables", () => {
5
+ const store = new McpStore(":memory:");
6
+ const run = store.createRun({
7
+ task: "health",
8
+ mode: "single",
9
+ budget: "low",
10
+ });
11
+
12
+ expect(run.status).toBe("queued");
13
+ expect(run.id.length).toBeGreaterThan(10);
14
+ store.close();
15
+ });
@@ -0,0 +1,7 @@
1
+ import { expect, test } from "bun:test";
2
+ import { shouldStopForBudget } from "../src/runtime/executor.ts";
3
+
4
+ test("stops run when estimated cost exceeds max", () => {
5
+ expect(shouldStopForBudget({ spentUsd: 5.1, maxUsd: 5 })).toBe(true);
6
+ expect(shouldStopForBudget({ spentUsd: 4.9, maxUsd: 5 })).toBe(false);
7
+ });