@aprovan/stitchery 0.1.0-dev.03aaf5b

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,91 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ export interface LocalPackagesContext {
6
+ localPackages: Record<string, string>;
7
+ log: (...args: unknown[]) => void;
8
+ }
9
+
10
+ export function handleLocalPackages(
11
+ req: IncomingMessage,
12
+ res: ServerResponse,
13
+ ctx: LocalPackagesContext,
14
+ ): boolean {
15
+ const rawUrl = req.url || '';
16
+
17
+ // Only handle /_local-packages routes
18
+ if (!rawUrl.startsWith('/_local-packages')) {
19
+ return false;
20
+ }
21
+
22
+ const urlWithoutPrefix = rawUrl.replace('/_local-packages', '');
23
+
24
+ // Strip query string (bundlers add ?import to dynamic imports)
25
+ const url = urlWithoutPrefix.split('?')[0] || '';
26
+
27
+ // Parse the package name from URL (handles scoped packages like @scope/name)
28
+ const match = url.match(/^\/@([^/]+)\/([^/@]+)(.*)$/);
29
+ if (!match) {
30
+ return false;
31
+ }
32
+
33
+ const [, scope, name, restPath] = match;
34
+ const packageName = `@${scope}/${name}`;
35
+ const localPath = ctx.localPackages[packageName];
36
+
37
+ if (!localPath) {
38
+ res.writeHead(404);
39
+ res.end(`Package ${packageName} not found in local overrides`);
40
+ return true;
41
+ }
42
+
43
+ // Determine what file to serve
44
+ const rest = restPath || '';
45
+ let filePath: string;
46
+
47
+ try {
48
+ if (rest === '/package.json') {
49
+ filePath = path.join(localPath, 'package.json');
50
+ } else if (rest === '' || rest === '/') {
51
+ const pkgJson = JSON.parse(
52
+ fs.readFileSync(path.join(localPath, 'package.json'), 'utf-8'),
53
+ );
54
+ const mainEntry = pkgJson.main || 'dist/index.js';
55
+ filePath = path.join(localPath, mainEntry);
56
+ } else {
57
+ const normalizedPath = rest.startsWith('/') ? rest.slice(1) : rest;
58
+ const distPath = path.join(localPath, 'dist', normalizedPath);
59
+ const rootPath = path.join(localPath, normalizedPath);
60
+ filePath = fs.existsSync(distPath) ? distPath : rootPath;
61
+ }
62
+ } catch (err) {
63
+ ctx.log('Error resolving file path:', err);
64
+ res.writeHead(500);
65
+ res.end(`Error resolving path for ${packageName}: ${err}`);
66
+ return true;
67
+ }
68
+
69
+ try {
70
+ ctx.log(`Serving ${filePath}`);
71
+ const content = fs.readFileSync(filePath, 'utf-8');
72
+ const ext = path.extname(filePath);
73
+ const contentType =
74
+ ext === '.json'
75
+ ? 'application/json'
76
+ : ext === '.js'
77
+ ? 'application/javascript'
78
+ : ext === '.ts'
79
+ ? 'application/typescript'
80
+ : 'text/plain';
81
+ res.setHeader('Content-Type', contentType);
82
+ res.writeHead(200);
83
+ res.end(content);
84
+ } catch (err) {
85
+ ctx.log('Error serving file:', filePath, err);
86
+ res.writeHead(404);
87
+ res.end(`File not found: ${filePath}`);
88
+ }
89
+
90
+ return true;
91
+ }
@@ -0,0 +1,122 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
3
+ import {
4
+ streamText,
5
+ convertToModelMessages,
6
+ stepCountIs,
7
+ type UIMessage,
8
+ type Tool,
9
+ } from 'ai';
10
+ import { PATCHWORK_PROMPT, EDIT_PROMPT } from '../prompts.js';
11
+ import type { ServiceRegistry } from './services.js';
12
+
13
+ export interface RouteContext {
14
+ copilotProxyUrl: string;
15
+ tools: Record<string, Tool>;
16
+ registry: ServiceRegistry;
17
+ servicesPrompt: string;
18
+ log: (...args: unknown[]) => void;
19
+ }
20
+
21
+ function parseBody<T>(req: IncomingMessage): Promise<T> {
22
+ return new Promise((resolve, reject) => {
23
+ let body = '';
24
+ req.on('data', (chunk) => (body += chunk));
25
+ req.on('end', () => {
26
+ try {
27
+ resolve(JSON.parse(body));
28
+ } catch (err) {
29
+ reject(err);
30
+ }
31
+ });
32
+ req.on('error', reject);
33
+ });
34
+ }
35
+
36
+ export async function handleChat(
37
+ req: IncomingMessage,
38
+ res: ServerResponse,
39
+ ctx: RouteContext,
40
+ ): Promise<void> {
41
+ const {
42
+ messages,
43
+ metadata,
44
+ }: {
45
+ messages: UIMessage[];
46
+ metadata?: { patchwork?: { compilers?: string[] } };
47
+ } = await parseBody(req);
48
+
49
+ const normalizedMessages = messages.map((msg) => ({
50
+ ...msg,
51
+ parts: msg.parts ?? [{ type: 'text' as const, text: '' }],
52
+ }));
53
+
54
+ const provider = createOpenAICompatible({
55
+ name: 'copilot-proxy',
56
+ baseURL: ctx.copilotProxyUrl,
57
+ });
58
+
59
+ const result = streamText({
60
+ model: provider('claude-sonnet-4'),
61
+ system: `---\npatchwork:\n compilers: ${
62
+ (metadata?.patchwork?.compilers ?? []).join(',') ?? '[]'
63
+ }\n services: ${ctx.registry
64
+ .getNamespaces()
65
+ .join(',')}\n---\n\n${PATCHWORK_PROMPT}\n\n${ctx.servicesPrompt}`,
66
+ messages: await convertToModelMessages(normalizedMessages),
67
+ stopWhen: stepCountIs(5),
68
+ tools: ctx.tools,
69
+ });
70
+
71
+ const response = result.toUIMessageStreamResponse();
72
+ response.headers.forEach((value: string, key: string) =>
73
+ res.setHeader(key, value),
74
+ );
75
+
76
+ if (!response.body) {
77
+ res.end();
78
+ return;
79
+ }
80
+
81
+ const reader = response.body.getReader();
82
+ const pump = async () => {
83
+ const { done, value } = await reader.read();
84
+ if (done) {
85
+ res.end();
86
+ return;
87
+ }
88
+ res.write(value);
89
+ await pump();
90
+ };
91
+ await pump();
92
+ }
93
+
94
+ export async function handleEdit(
95
+ req: IncomingMessage,
96
+ res: ServerResponse,
97
+ ctx: RouteContext,
98
+ ): Promise<void> {
99
+ const { code, prompt }: { code: string; prompt: string } = await parseBody(
100
+ req,
101
+ );
102
+
103
+ const provider = createOpenAICompatible({
104
+ name: 'copilot-proxy',
105
+ baseURL: ctx.copilotProxyUrl,
106
+ });
107
+
108
+ const result = streamText({
109
+ model: provider('claude-opus-4.5'),
110
+ system: `Current component code:\n\`\`\`tsx\n${code}\n\`\`\`\n\n${EDIT_PROMPT}`,
111
+ messages: [{ role: 'user', content: prompt }],
112
+ });
113
+
114
+ res.setHeader('Content-Type', 'text/plain');
115
+ res.setHeader('Transfer-Encoding', 'chunked');
116
+ res.writeHead(200);
117
+
118
+ for await (const chunk of result.textStream) {
119
+ res.write(chunk);
120
+ }
121
+ res.end();
122
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Service Registry - Tracks available services for widget calls
3
+ *
4
+ * Services can be registered from:
5
+ * - MCP servers (via --mcp CLI)
6
+ * - External backends (UTCP, HTTP, etc.)
7
+ *
8
+ * Provides unified interface for calling services and exposing metadata.
9
+ */
10
+
11
+ import { jsonSchema, type Tool } from 'ai';
12
+
13
+ /**
14
+ * Service backend interface - abstracts service call mechanisms
15
+ * Backends can be UTCP, HTTP proxies, direct MCP, etc.
16
+ */
17
+ export interface ServiceBackend {
18
+ call(service: string, procedure: string, args: unknown[]): Promise<unknown>;
19
+ }
20
+
21
+ /**
22
+ * Service tool metadata for prompt generation
23
+ */
24
+ export interface ServiceToolInfo {
25
+ /** Full tool name (e.g., 'weather.get_forecast') */
26
+ name: string;
27
+ /** Namespace (e.g., 'weather') */
28
+ namespace: string;
29
+ /** Procedure name (e.g., 'get_forecast') */
30
+ procedure: string;
31
+ /** Tool description */
32
+ description?: string;
33
+ /** Parameter schema */
34
+ parameters?: Record<string, unknown>;
35
+ /** TypeScript interface definition (optional, for search results) */
36
+ typescriptInterface?: string;
37
+ }
38
+
39
+ /**
40
+ * Search options for tool discovery
41
+ */
42
+ export interface SearchServicesOptions {
43
+ /** Natural language task description to search for */
44
+ query?: string;
45
+ /** Filter by namespace */
46
+ namespace?: string;
47
+ /** Maximum results to return */
48
+ limit?: number;
49
+ /** Include full TypeScript interfaces in results */
50
+ includeInterfaces?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Service registry that tracks available services
55
+ */
56
+ export class ServiceRegistry {
57
+ private tools: Map<string, Tool> = new Map();
58
+ private toolInfo: Map<string, ServiceToolInfo> = new Map();
59
+ private backends: ServiceBackend[] = [];
60
+
61
+ /**
62
+ * Register tools from MCP or other sources
63
+ * @param tools - Record of tool name to Tool
64
+ * @param namespace - Optional namespace to prefix all tools (e.g., MCP server name)
65
+ */
66
+ registerTools(tools: Record<string, Tool>, namespace?: string): void {
67
+ for (const [toolName, tool] of Object.entries(tools)) {
68
+ // Build the full name: namespace.toolName or just toolName
69
+ const name = namespace ? `${namespace}.${toolName}` : toolName;
70
+ this.tools.set(name, tool);
71
+
72
+ // Parse namespace and procedure from the full name using '.' separator
73
+ const dotIndex = name.indexOf('.');
74
+ const ns = dotIndex > 0 ? name.substring(0, dotIndex) : name;
75
+ const procedure = dotIndex > 0 ? name.substring(dotIndex + 1) : name;
76
+
77
+ this.toolInfo.set(name, {
78
+ name,
79
+ namespace: ns,
80
+ procedure,
81
+ description: tool.description,
82
+ parameters: (tool.inputSchema ?? {}) as Record<string, unknown>,
83
+ typescriptInterface: this.generateTypeScriptInterface(name, tool),
84
+ });
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Register a service backend (UTCP, HTTP, etc.)
90
+ * Creates callable Tool objects for each procedure so the LLM can invoke them directly.
91
+ * Backends are tried in order of registration, first success wins.
92
+ */
93
+ registerBackend(
94
+ backend: ServiceBackend,
95
+ toolInfos?: ServiceToolInfo[],
96
+ ): void {
97
+ this.backends.push(backend);
98
+ if (toolInfos) {
99
+ for (const info of toolInfos) {
100
+ this.toolInfo.set(info.name, info);
101
+
102
+ // Create a callable Tool object for LLM use
103
+ const tool: Tool = {
104
+ description: info.description,
105
+ inputSchema: jsonSchema(
106
+ info.parameters ?? { type: 'object', properties: {} },
107
+ ),
108
+ execute: async (args: unknown) => {
109
+ return backend.call(info.namespace, info.procedure, [args]);
110
+ },
111
+ };
112
+ this.tools.set(info.name, tool);
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Generate TypeScript interface from tool schema
119
+ */
120
+ private generateTypeScriptInterface(name: string, tool: Tool): string {
121
+ const schema = tool.inputSchema as Record<string, unknown> | undefined;
122
+ const props = (schema?.properties ?? {}) as Record<
123
+ string,
124
+ { type?: string; description?: string }
125
+ >;
126
+ const required = (schema?.required ?? []) as string[];
127
+
128
+ const params = Object.entries(props)
129
+ .map(([key, val]) => {
130
+ const optional = !required.includes(key) ? '?' : '';
131
+ const type =
132
+ val.type === 'number'
133
+ ? 'number'
134
+ : val.type === 'boolean'
135
+ ? 'boolean'
136
+ : val.type === 'array'
137
+ ? 'unknown[]'
138
+ : val.type === 'object'
139
+ ? 'Record<string, unknown>'
140
+ : 'string';
141
+ const comment = val.description ? ` // ${val.description}` : '';
142
+ return ` ${key}${optional}: ${type};${comment}`;
143
+ })
144
+ .join('\n');
145
+
146
+ return `interface ${name.replace(
147
+ /[^a-zA-Z0-9]/g,
148
+ '_',
149
+ )}Args {\n${params}\n}`;
150
+ }
151
+
152
+ /**
153
+ * Convert internal tool name (namespace.procedure) to LLM-safe name (namespace_procedure)
154
+ * OpenAI-compatible APIs require tool names to match ^[a-zA-Z0-9_-]+$
155
+ */
156
+ private toLLMToolName(internalName: string): string {
157
+ return internalName.replace(/\./g, '_');
158
+ }
159
+
160
+ /**
161
+ * Convert LLM tool name (namespace_procedure) back to internal name (namespace.procedure)
162
+ * Only converts the first underscore after the namespace prefix
163
+ */
164
+ private fromLLMToolName(llmName: string): string {
165
+ // Find the tool by checking if any registered tool converts to this LLM name
166
+ for (const internalName of this.tools.keys()) {
167
+ if (this.toLLMToolName(internalName) === llmName) {
168
+ return internalName;
169
+ }
170
+ }
171
+ // Fallback: convert first underscore to dot
172
+ const underscoreIndex = llmName.indexOf('_');
173
+ if (underscoreIndex > 0) {
174
+ return (
175
+ llmName.substring(0, underscoreIndex) +
176
+ '.' +
177
+ llmName.substring(underscoreIndex + 1)
178
+ );
179
+ }
180
+ return llmName;
181
+ }
182
+
183
+ /**
184
+ * Get all tools for LLM usage with LLM-safe names (underscores instead of dots)
185
+ */
186
+ getTools(): Record<string, Tool> {
187
+ const result: Record<string, Tool> = {};
188
+ for (const [name, tool] of this.tools) {
189
+ result[this.toLLMToolName(name)] = tool;
190
+ }
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * Get service metadata for prompt generation
196
+ */
197
+ getServiceInfo(): ServiceToolInfo[] {
198
+ return Array.from(this.toolInfo.values());
199
+ }
200
+
201
+ /**
202
+ * Get unique namespaces
203
+ */
204
+ getNamespaces(): string[] {
205
+ const namespaces = new Set<string>();
206
+ for (const info of this.toolInfo.values()) {
207
+ namespaces.add(info.namespace);
208
+ }
209
+ return Array.from(namespaces);
210
+ }
211
+
212
+ /**
213
+ * Search for services by query, namespace, or list all
214
+ */
215
+ searchServices(options: SearchServicesOptions = {}): ServiceToolInfo[] {
216
+ const { query, namespace, limit = 20, includeInterfaces = false } = options;
217
+
218
+ let results = Array.from(this.toolInfo.values());
219
+
220
+ // Filter by namespace
221
+ if (namespace) {
222
+ results = results.filter((info) => info.namespace === namespace);
223
+ }
224
+
225
+ // Search by query (simple keyword matching)
226
+ if (query) {
227
+ const queryLower = query.toLowerCase();
228
+ const keywords = queryLower.split(/\s+/).filter(Boolean);
229
+
230
+ results = results
231
+ .map((info) => {
232
+ const searchText = `${info.name} ${info.namespace} ${
233
+ info.procedure
234
+ } ${info.description ?? ''}`.toLowerCase();
235
+ const matchCount = keywords.filter((kw) =>
236
+ searchText.includes(kw),
237
+ ).length;
238
+ return { info, score: matchCount / keywords.length };
239
+ })
240
+ .filter(({ score }) => score > 0)
241
+ .sort((a, b) => b.score - a.score)
242
+ .map(({ info }) => info);
243
+ }
244
+
245
+ // Apply limit
246
+ results = results.slice(0, limit);
247
+
248
+ // Optionally include TypeScript interfaces
249
+ if (!includeInterfaces) {
250
+ results = results.map(({ typescriptInterface: _, ...rest }) => rest);
251
+ }
252
+
253
+ return results;
254
+ }
255
+
256
+ /**
257
+ * Get detailed info about a specific tool
258
+ */
259
+ getToolInfo(toolName: string): ServiceToolInfo | undefined {
260
+ return this.toolInfo.get(toolName);
261
+ }
262
+
263
+ /**
264
+ * List all tool names
265
+ */
266
+ listToolNames(): string[] {
267
+ return Array.from(this.toolInfo.keys());
268
+ }
269
+
270
+ /**
271
+ * Call a service procedure
272
+ */
273
+ async call(
274
+ namespace: string,
275
+ procedure: string,
276
+ args: unknown,
277
+ ): Promise<unknown> {
278
+ // Try registered backends first (in order)
279
+ for (const backend of this.backends) {
280
+ try {
281
+ return await backend.call(namespace, procedure, [args]);
282
+ } catch {
283
+ // Try next backend
284
+ }
285
+ }
286
+
287
+ // Fall back to MCP tools - use dot separator
288
+ const exactKey = `${namespace}.${procedure}`;
289
+ let tool = this.tools.get(exactKey);
290
+
291
+ if (!tool) {
292
+ // Try finding by namespace prefix match
293
+ for (const [name, t] of this.tools) {
294
+ if (name.startsWith(`${namespace}.`) && name.endsWith(procedure)) {
295
+ tool = t;
296
+ break;
297
+ }
298
+ }
299
+ }
300
+
301
+ if (!tool) {
302
+ throw new Error(
303
+ `Service not found: ${namespace}.${procedure}. Available: ${Array.from(
304
+ this.tools.keys(),
305
+ )
306
+ .slice(0, 10)
307
+ .join(', ')}`,
308
+ );
309
+ }
310
+
311
+ // Execute the tool
312
+ if (!tool.execute) {
313
+ throw new Error(`Tool ${namespace}.${procedure} has no execute function`);
314
+ }
315
+
316
+ const result = await tool.execute(args as Record<string, unknown>, {
317
+ toolCallId: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
318
+ messages: [],
319
+ });
320
+
321
+ return result;
322
+ }
323
+
324
+ /**
325
+ * Check if a service exists
326
+ */
327
+ has(namespace: string, procedure: string): boolean {
328
+ const key = `${namespace}.${procedure}`;
329
+ return this.tools.has(key);
330
+ }
331
+
332
+ /**
333
+ * Get count of registered tools
334
+ */
335
+ get size(): number {
336
+ return this.toolInfo.size;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Generate a services description for prompts
342
+ */
343
+ export function generateServicesPrompt(registry: ServiceRegistry): string {
344
+ const namespaces = registry.getNamespaces();
345
+ if (namespaces.length === 0) return '';
346
+
347
+ const services = registry.getServiceInfo();
348
+ const byNamespace = new Map<string, ServiceToolInfo[]>();
349
+
350
+ for (const service of services) {
351
+ const existing = byNamespace.get(service.namespace) || [];
352
+ existing.push(service);
353
+ byNamespace.set(service.namespace, existing);
354
+ }
355
+
356
+ let prompt = `## Available Services\n\nThe following services are available for generated widgets to call:\n\n`;
357
+
358
+ for (const [ns, tools] of byNamespace) {
359
+ prompt += `### \`${ns}\`\n`;
360
+ for (const tool of tools) {
361
+ prompt += `- \`${ns}.${tool.procedure}()\``;
362
+ if (tool.description) {
363
+ prompt += `: ${tool.description}`;
364
+ }
365
+ prompt += '\n';
366
+ }
367
+ prompt += '\n';
368
+ }
369
+
370
+ prompt += `**Usage in widgets:**
371
+ \`\`\`tsx
372
+ // Services are available as global namespaces
373
+ const result = await ${namespaces[0] ?? 'service'}.${
374
+ byNamespace.get(namespaces[0] ?? '')?.[0]?.procedure ?? 'example'
375
+ }({ /* args */ });
376
+ \`\`\`
377
+
378
+ Make sure to handle loading states and errors when calling services.
379
+ `;
380
+
381
+ return prompt;
382
+ }