@aprovan/stitchery 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,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
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ readFile,
3
+ writeFile,
4
+ unlink,
5
+ readdir,
6
+ stat,
7
+ mkdir,
8
+ } from 'node:fs/promises';
9
+ import { join, relative, dirname } from 'node:path';
10
+ import type { IncomingMessage, ServerResponse } from 'node:http';
11
+
12
+ export interface VFSContext {
13
+ rootDir: string;
14
+ usePaths: boolean;
15
+ log: (...args: unknown[]) => void;
16
+ }
17
+
18
+ async function listFilesRecursive(dir: string): Promise<string[]> {
19
+ try {
20
+ const entries = await readdir(dir, { withFileTypes: true });
21
+ const files: string[] = [];
22
+ for (const entry of entries) {
23
+ const full = join(dir, entry.name);
24
+ if (entry.isDirectory()) {
25
+ files.push(...(await listFilesRecursive(full)));
26
+ } else {
27
+ files.push(full);
28
+ }
29
+ }
30
+ return files;
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ async function ensureDir(filePath: string): Promise<void> {
37
+ const dir = dirname(filePath);
38
+ await mkdir(dir, { recursive: true });
39
+ }
40
+
41
+ export function handleVFS(
42
+ req: IncomingMessage,
43
+ res: ServerResponse,
44
+ ctx: VFSContext,
45
+ ): boolean {
46
+ const url = req.url || '/';
47
+ const method = req.method || 'GET';
48
+
49
+ if (!url.startsWith('/vfs')) return false;
50
+
51
+ // Handle config endpoint
52
+ if (url === '/vfs/config' && method === 'GET') {
53
+ res.setHeader('Content-Type', 'application/json');
54
+ res.writeHead(200);
55
+ res.end(JSON.stringify({ usePaths: ctx.usePaths }));
56
+ return true;
57
+ }
58
+
59
+ const handleRequest = async () => {
60
+ const path = url.slice(4).split('?')[0] || '';
61
+ const query = new URL(url, 'http://localhost').searchParams;
62
+
63
+ if (path === '' || path === '/') {
64
+ if (method === 'GET') {
65
+ const prefix = query.get('prefix') || '';
66
+ const files = await listFilesRecursive(join(ctx.rootDir, prefix));
67
+ const relativePaths = files.map((f) => relative(ctx.rootDir, f));
68
+ res.setHeader('Content-Type', 'application/json');
69
+ res.writeHead(200);
70
+ res.end(JSON.stringify(relativePaths));
71
+ return;
72
+ }
73
+ }
74
+
75
+ const filePath = join(ctx.rootDir, path.slice(1));
76
+
77
+ switch (method) {
78
+ case 'GET': {
79
+ try {
80
+ const content = await readFile(filePath, 'utf-8');
81
+ res.setHeader('Content-Type', 'text/plain');
82
+ res.writeHead(200);
83
+ res.end(content);
84
+ } catch {
85
+ res.writeHead(404);
86
+ res.end('Not found');
87
+ }
88
+ return;
89
+ }
90
+ case 'PUT': {
91
+ let body = '';
92
+ req.on('data', (chunk) => (body += chunk));
93
+ req.on('end', async () => {
94
+ try {
95
+ await ensureDir(filePath);
96
+ await writeFile(filePath, body, 'utf-8');
97
+ res.writeHead(200);
98
+ res.end('ok');
99
+ } catch (err) {
100
+ ctx.log('VFS PUT error:', err);
101
+ res.writeHead(500);
102
+ res.end('Write failed');
103
+ }
104
+ });
105
+ return;
106
+ }
107
+ case 'DELETE': {
108
+ try {
109
+ await unlink(filePath);
110
+ res.writeHead(200);
111
+ res.end('ok');
112
+ } catch {
113
+ res.writeHead(404);
114
+ res.end('Not found');
115
+ }
116
+ return;
117
+ }
118
+ case 'HEAD': {
119
+ try {
120
+ await stat(filePath);
121
+ res.writeHead(200);
122
+ res.end();
123
+ } catch {
124
+ res.writeHead(404);
125
+ res.end();
126
+ }
127
+ return;
128
+ }
129
+ default:
130
+ res.writeHead(405);
131
+ res.end('Method not allowed');
132
+ }
133
+ };
134
+
135
+ handleRequest().catch((err) => {
136
+ ctx.log('VFS error:', err);
137
+ res.writeHead(500);
138
+ res.end('Internal error');
139
+ });
140
+
141
+ return true;
142
+ }
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * UTCP service configuration
3
+ * Used to register services via UTCP protocol
4
+ */
5
+ export interface UtcpConfig {
6
+ /** Working directory for UTCP operations */
7
+ cwd?: string;
8
+ /** Manual call templates (service definitions) */
9
+ manual_call_templates?: Array<{
10
+ name: string;
11
+ call_template_type: string;
12
+ url?: string;
13
+ http_method?: string;
14
+ [key: string]: unknown;
15
+ }>;
16
+ /** Additional UTCP options */
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ export interface ServerConfig {
21
+ port: number;
22
+ host: string;
23
+ copilotProxyUrl: string;
24
+ localPackages: Record<string, string>;
25
+ mcpServers: McpServerConfig[];
26
+ /** UTCP configuration for registering services */
27
+ utcp?: UtcpConfig;
28
+ /** Directory for virtual file system storage */
29
+ vfsDir?: string;
30
+ /** Use file paths from code blocks instead of UUIDs */
31
+ vfsUsePaths?: boolean;
32
+ verbose: boolean;
33
+ }
34
+
35
+ export interface McpServerConfig {
36
+ name: string;
37
+ command: string;
38
+ args: string[];
39
+ }
40
+
41
+ export interface ChatRequest {
42
+ messages: UIMessage[];
43
+ metadata?: {
44
+ patchwork?: {
45
+ compilers?: string[];
46
+ };
47
+ };
48
+ }
49
+
50
+ export interface EditRequest {
51
+ code: string;
52
+ prompt: string;
53
+ }
54
+
55
+ export interface UIMessage {
56
+ role: string;
57
+ content: string;
58
+ parts?: Array<{ type: string; text: string }>;
59
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "module": "ESNext",
9
+ "moduleResolution": "bundler"
10
+ },
11
+ "include": ["src/**/*"],
12
+ "exclude": ["node_modules", "dist"]
13
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: {
5
+ index: 'src/index.ts',
6
+ cli: 'src/cli.ts',
7
+ 'server/index': 'src/server/index.ts',
8
+ },
9
+ format: ['esm'],
10
+ dts: true,
11
+ clean: true,
12
+ sourcemap: true,
13
+ splitting: false,
14
+ shims: true,
15
+ });