tlc-claude-code 1.4.1 → 1.4.4

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.
Files changed (91) hide show
  1. package/dashboard/dist/App.js +229 -35
  2. package/dashboard/dist/components/AgentRegistryPane.d.ts +35 -0
  3. package/dashboard/dist/components/AgentRegistryPane.js +89 -0
  4. package/dashboard/dist/components/AgentRegistryPane.test.d.ts +1 -0
  5. package/dashboard/dist/components/AgentRegistryPane.test.js +200 -0
  6. package/dashboard/dist/components/RouterPane.d.ts +5 -0
  7. package/dashboard/dist/components/RouterPane.js +65 -0
  8. package/dashboard/dist/components/RouterPane.test.d.ts +1 -0
  9. package/dashboard/dist/components/RouterPane.test.js +176 -0
  10. package/dashboard/dist/components/accessibility.test.d.ts +1 -0
  11. package/dashboard/dist/components/accessibility.test.js +116 -0
  12. package/dashboard/dist/components/layout/MobileNav.d.ts +16 -0
  13. package/dashboard/dist/components/layout/MobileNav.js +31 -0
  14. package/dashboard/dist/components/layout/MobileNav.test.d.ts +1 -0
  15. package/dashboard/dist/components/layout/MobileNav.test.js +111 -0
  16. package/dashboard/dist/components/performance.test.d.ts +1 -0
  17. package/dashboard/dist/components/performance.test.js +114 -0
  18. package/dashboard/dist/components/responsive.test.d.ts +1 -0
  19. package/dashboard/dist/components/responsive.test.js +114 -0
  20. package/dashboard/dist/components/ui/Dropdown.d.ts +22 -0
  21. package/dashboard/dist/components/ui/Dropdown.js +109 -0
  22. package/dashboard/dist/components/ui/Dropdown.test.d.ts +1 -0
  23. package/dashboard/dist/components/ui/Dropdown.test.js +105 -0
  24. package/dashboard/dist/components/ui/Modal.d.ts +13 -0
  25. package/dashboard/dist/components/ui/Modal.js +25 -0
  26. package/dashboard/dist/components/ui/Modal.test.d.ts +1 -0
  27. package/dashboard/dist/components/ui/Modal.test.js +91 -0
  28. package/dashboard/dist/components/ui/Skeleton.d.ts +32 -0
  29. package/dashboard/dist/components/ui/Skeleton.js +48 -0
  30. package/dashboard/dist/components/ui/Skeleton.test.d.ts +1 -0
  31. package/dashboard/dist/components/ui/Skeleton.test.js +125 -0
  32. package/dashboard/dist/components/ui/Toast.d.ts +32 -0
  33. package/dashboard/dist/components/ui/Toast.js +21 -0
  34. package/dashboard/dist/components/ui/Toast.test.d.ts +1 -0
  35. package/dashboard/dist/components/ui/Toast.test.js +118 -0
  36. package/dashboard/dist/hooks/useTheme.d.ts +37 -0
  37. package/dashboard/dist/hooks/useTheme.js +96 -0
  38. package/dashboard/dist/hooks/useTheme.test.d.ts +1 -0
  39. package/dashboard/dist/hooks/useTheme.test.js +94 -0
  40. package/dashboard/dist/hooks/useWebSocket.d.ts +17 -0
  41. package/dashboard/dist/hooks/useWebSocket.js +100 -0
  42. package/dashboard/dist/hooks/useWebSocket.test.d.ts +1 -0
  43. package/dashboard/dist/hooks/useWebSocket.test.js +115 -0
  44. package/dashboard/dist/stores/projectStore.d.ts +44 -0
  45. package/dashboard/dist/stores/projectStore.js +76 -0
  46. package/dashboard/dist/stores/projectStore.test.d.ts +1 -0
  47. package/dashboard/dist/stores/projectStore.test.js +114 -0
  48. package/dashboard/dist/stores/uiStore.d.ts +29 -0
  49. package/dashboard/dist/stores/uiStore.js +72 -0
  50. package/dashboard/dist/stores/uiStore.test.d.ts +1 -0
  51. package/dashboard/dist/stores/uiStore.test.js +93 -0
  52. package/dashboard/package.json +3 -3
  53. package/docker-compose.dev.yml +6 -1
  54. package/package.json +5 -2
  55. package/server/dashboard/index.html +1336 -779
  56. package/server/index.js +178 -0
  57. package/server/lib/agent-cleanup.js +177 -0
  58. package/server/lib/agent-cleanup.test.js +359 -0
  59. package/server/lib/agent-hooks.js +126 -0
  60. package/server/lib/agent-hooks.test.js +303 -0
  61. package/server/lib/agent-metadata.js +179 -0
  62. package/server/lib/agent-metadata.test.js +383 -0
  63. package/server/lib/agent-persistence.js +191 -0
  64. package/server/lib/agent-persistence.test.js +475 -0
  65. package/server/lib/agent-registry-command.js +340 -0
  66. package/server/lib/agent-registry-command.test.js +334 -0
  67. package/server/lib/agent-registry.js +155 -0
  68. package/server/lib/agent-registry.test.js +239 -0
  69. package/server/lib/agent-state.js +236 -0
  70. package/server/lib/agent-state.test.js +375 -0
  71. package/server/lib/api-provider.js +186 -0
  72. package/server/lib/api-provider.test.js +336 -0
  73. package/server/lib/cli-detector.js +166 -0
  74. package/server/lib/cli-detector.test.js +269 -0
  75. package/server/lib/cli-provider.js +212 -0
  76. package/server/lib/cli-provider.test.js +349 -0
  77. package/server/lib/debug.test.js +62 -0
  78. package/server/lib/devserver-router-api.js +249 -0
  79. package/server/lib/devserver-router-api.test.js +426 -0
  80. package/server/lib/model-router.js +245 -0
  81. package/server/lib/model-router.test.js +313 -0
  82. package/server/lib/output-schemas.js +269 -0
  83. package/server/lib/output-schemas.test.js +307 -0
  84. package/server/lib/provider-interface.js +153 -0
  85. package/server/lib/provider-interface.test.js +394 -0
  86. package/server/lib/provider-queue.js +158 -0
  87. package/server/lib/provider-queue.test.js +315 -0
  88. package/server/lib/router-config.js +221 -0
  89. package/server/lib/router-config.test.js +237 -0
  90. package/server/lib/router-setup-command.js +419 -0
  91. package/server/lib/router-setup-command.test.js +375 -0
@@ -0,0 +1,313 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ createRouter,
4
+ resolveProvider,
5
+ resolveCapability,
6
+ loadConfig,
7
+ DEFAULT_CONFIG,
8
+ } from './model-router.js';
9
+
10
+ // Mock dependencies
11
+ vi.mock('./cli-detector.js', () => ({
12
+ detectAllCLIs: vi.fn(),
13
+ clearCache: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('fs/promises', () => ({
17
+ default: {
18
+ readFile: vi.fn(),
19
+ },
20
+ }));
21
+
22
+ import { detectAllCLIs } from './cli-detector.js';
23
+ import fs from 'fs/promises';
24
+
25
+ describe('model-router', () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ describe('createRouter', () => {
31
+ it('creates router with default config', async () => {
32
+ detectAllCLIs.mockResolvedValue(new Map());
33
+
34
+ const router = await createRouter();
35
+
36
+ expect(router).toBeDefined();
37
+ expect(router.resolveProvider).toBeDefined();
38
+ expect(router.resolveCapability).toBeDefined();
39
+ });
40
+
41
+ it('creates router with custom config', async () => {
42
+ detectAllCLIs.mockResolvedValue(new Map());
43
+
44
+ const config = {
45
+ providers: {
46
+ claude: { type: 'cli', command: 'claude' },
47
+ },
48
+ capabilities: {
49
+ review: { providers: ['claude'] },
50
+ },
51
+ };
52
+
53
+ const router = await createRouter(config);
54
+
55
+ expect(router).toBeDefined();
56
+ });
57
+
58
+ it('detects local CLIs on creation', async () => {
59
+ detectAllCLIs.mockResolvedValue(new Map([
60
+ ['claude', { name: 'claude', version: 'v4.0.0' }],
61
+ ]));
62
+
63
+ await createRouter();
64
+
65
+ expect(detectAllCLIs).toHaveBeenCalled();
66
+ });
67
+ });
68
+
69
+ describe('resolveProvider', () => {
70
+ it('returns local when CLI detected', async () => {
71
+ detectAllCLIs.mockResolvedValue(new Map([
72
+ ['claude', { name: 'claude', version: 'v4.0.0', detected: true }],
73
+ ]));
74
+
75
+ const router = await createRouter({
76
+ providers: {
77
+ claude: { type: 'cli', command: 'claude', capabilities: ['review'] },
78
+ },
79
+ });
80
+
81
+ const result = router.resolveProvider('claude');
82
+
83
+ expect(result.via).toBe('local');
84
+ expect(result.provider.name).toBe('claude');
85
+ });
86
+
87
+ it('returns devserver when CLI not detected', async () => {
88
+ detectAllCLIs.mockResolvedValue(new Map()); // No CLIs detected
89
+
90
+ const router = await createRouter({
91
+ providers: {
92
+ claude: { type: 'cli', command: 'claude', capabilities: ['review'] },
93
+ },
94
+ devserver: { url: 'https://devserver.example.com' },
95
+ });
96
+
97
+ const result = router.resolveProvider('claude');
98
+
99
+ expect(result.via).toBe('devserver');
100
+ });
101
+
102
+ it('returns devserver for API type', async () => {
103
+ detectAllCLIs.mockResolvedValue(new Map());
104
+
105
+ const router = await createRouter({
106
+ providers: {
107
+ deepseek: {
108
+ type: 'api',
109
+ baseUrl: 'https://api.deepseek.com',
110
+ capabilities: ['review'],
111
+ },
112
+ },
113
+ });
114
+
115
+ const result = router.resolveProvider('deepseek');
116
+
117
+ expect(result.via).toBe('devserver');
118
+ });
119
+
120
+ it('returns null for unknown provider', async () => {
121
+ detectAllCLIs.mockResolvedValue(new Map());
122
+
123
+ const router = await createRouter();
124
+
125
+ const result = router.resolveProvider('unknown');
126
+
127
+ expect(result).toBeNull();
128
+ });
129
+ });
130
+
131
+ describe('resolveCapability', () => {
132
+ it('returns ordered providers for capability', async () => {
133
+ detectAllCLIs.mockResolvedValue(new Map([
134
+ ['claude', { name: 'claude' }],
135
+ ]));
136
+
137
+ const router = await createRouter({
138
+ providers: {
139
+ claude: { type: 'cli', command: 'claude', capabilities: ['review'] },
140
+ deepseek: { type: 'api', baseUrl: 'https://api.deepseek.com', capabilities: ['review'] },
141
+ },
142
+ capabilities: {
143
+ review: { providers: ['claude', 'deepseek'] },
144
+ },
145
+ });
146
+
147
+ const providers = router.resolveCapability('review');
148
+
149
+ expect(providers.length).toBe(2);
150
+ expect(providers[0].name).toBe('claude');
151
+ expect(providers[1].name).toBe('deepseek');
152
+ });
153
+
154
+ it('filters by capability', async () => {
155
+ detectAllCLIs.mockResolvedValue(new Map());
156
+
157
+ const router = await createRouter({
158
+ providers: {
159
+ claude: { type: 'cli', command: 'claude', capabilities: ['review', 'code-gen'] },
160
+ gemini: { type: 'cli', command: 'gemini', capabilities: ['design'] },
161
+ },
162
+ capabilities: {
163
+ review: { providers: ['claude'] },
164
+ design: { providers: ['gemini'] },
165
+ },
166
+ });
167
+
168
+ const reviewProviders = router.resolveCapability('review');
169
+ const designProviders = router.resolveCapability('design');
170
+
171
+ expect(reviewProviders.length).toBe(1);
172
+ expect(reviewProviders[0].name).toBe('claude');
173
+
174
+ expect(designProviders.length).toBe(1);
175
+ expect(designProviders[0].name).toBe('gemini');
176
+ });
177
+
178
+ it('returns empty array for unknown capability', async () => {
179
+ detectAllCLIs.mockResolvedValue(new Map());
180
+
181
+ const router = await createRouter();
182
+
183
+ const providers = router.resolveCapability('unknown');
184
+
185
+ expect(providers).toEqual([]);
186
+ });
187
+ });
188
+
189
+ describe('cascade behavior', () => {
190
+ it('tries local first', async () => {
191
+ detectAllCLIs.mockResolvedValue(new Map([
192
+ ['claude', { name: 'claude', detected: true }],
193
+ ]));
194
+
195
+ const router = await createRouter({
196
+ providers: {
197
+ claude: { type: 'cli', command: 'claude', capabilities: ['review'] },
198
+ },
199
+ devserver: { url: 'https://devserver.example.com' },
200
+ });
201
+
202
+ const result = router.resolveProvider('claude');
203
+
204
+ expect(result.via).toBe('local');
205
+ });
206
+
207
+ it('falls back to devserver when local unavailable', async () => {
208
+ detectAllCLIs.mockResolvedValue(new Map()); // No CLIs detected
209
+
210
+ const router = await createRouter({
211
+ providers: {
212
+ claude: { type: 'cli', command: 'claude', capabilities: ['review'] },
213
+ },
214
+ devserver: { url: 'https://devserver.example.com' },
215
+ });
216
+
217
+ const result = router.resolveProvider('claude');
218
+
219
+ expect(result.via).toBe('devserver');
220
+ });
221
+ });
222
+
223
+ describe('loadConfig', () => {
224
+ it('reads from .tlc.json', async () => {
225
+ const config = {
226
+ router: {
227
+ providers: { claude: { type: 'cli', command: 'claude' } },
228
+ },
229
+ };
230
+
231
+ fs.readFile.mockResolvedValue(JSON.stringify(config));
232
+
233
+ const loaded = await loadConfig('/project');
234
+
235
+ expect(fs.readFile).toHaveBeenCalledWith('/project/.tlc.json', 'utf8');
236
+ expect(loaded.providers.claude).toBeDefined();
237
+ });
238
+
239
+ it('uses defaults when file missing', async () => {
240
+ fs.readFile.mockRejectedValue(new Error('ENOENT'));
241
+
242
+ const loaded = await loadConfig('/project');
243
+
244
+ expect(loaded).toEqual(DEFAULT_CONFIG);
245
+ });
246
+
247
+ it('merges with defaults', async () => {
248
+ const config = {
249
+ router: {
250
+ providers: { custom: { type: 'api', baseUrl: 'https://example.com' } },
251
+ },
252
+ };
253
+
254
+ fs.readFile.mockResolvedValue(JSON.stringify(config));
255
+
256
+ const loaded = await loadConfig('/project');
257
+
258
+ // Should have custom provider
259
+ expect(loaded.providers.custom).toBeDefined();
260
+ // Should still have defaults
261
+ expect(loaded.providers.claude).toBeDefined();
262
+ });
263
+ });
264
+
265
+ describe('DEFAULT_CONFIG', () => {
266
+ it('includes claude provider', () => {
267
+ expect(DEFAULT_CONFIG.providers.claude).toBeDefined();
268
+ expect(DEFAULT_CONFIG.providers.claude.type).toBe('cli');
269
+ });
270
+
271
+ it('includes codex provider', () => {
272
+ expect(DEFAULT_CONFIG.providers.codex).toBeDefined();
273
+ expect(DEFAULT_CONFIG.providers.codex.type).toBe('cli');
274
+ });
275
+
276
+ it('includes gemini provider', () => {
277
+ expect(DEFAULT_CONFIG.providers.gemini).toBeDefined();
278
+ expect(DEFAULT_CONFIG.providers.gemini.type).toBe('cli');
279
+ });
280
+
281
+ it('includes deepseek provider', () => {
282
+ expect(DEFAULT_CONFIG.providers.deepseek).toBeDefined();
283
+ expect(DEFAULT_CONFIG.providers.deepseek.type).toBe('api');
284
+ });
285
+
286
+ it('includes review capability', () => {
287
+ expect(DEFAULT_CONFIG.capabilities.review).toBeDefined();
288
+ expect(DEFAULT_CONFIG.capabilities.review.providers).toContain('claude');
289
+ });
290
+ });
291
+
292
+ describe('handleUnavailable', () => {
293
+ it('skips unavailable providers', async () => {
294
+ detectAllCLIs.mockResolvedValue(new Map()); // No local CLIs
295
+
296
+ const router = await createRouter({
297
+ providers: {
298
+ claude: { type: 'cli', command: 'claude', capabilities: ['review'] },
299
+ deepseek: { type: 'api', baseUrl: 'https://api.deepseek.com', capabilities: ['review'] },
300
+ },
301
+ capabilities: {
302
+ review: { providers: ['claude', 'deepseek'] },
303
+ },
304
+ devserver: { url: 'https://devserver.example.com' },
305
+ });
306
+
307
+ const providers = router.resolveCapability('review');
308
+
309
+ // Both should be available (claude via devserver, deepseek via api)
310
+ expect(providers.length).toBe(2);
311
+ });
312
+ });
313
+ });
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Output Schemas - Standard JSON schemas for provider outputs
3
+ *
4
+ * Ensures consistent output format across all providers.
5
+ */
6
+
7
+ import fs from 'fs/promises';
8
+
9
+ /**
10
+ * Built-in schemas for common operations
11
+ */
12
+ export const BUILTIN_SCHEMAS = {
13
+ 'review-result': {
14
+ type: 'object',
15
+ properties: {
16
+ summary: { type: 'string', description: 'Brief summary of the review' },
17
+ issues: {
18
+ type: 'array',
19
+ items: {
20
+ type: 'object',
21
+ properties: {
22
+ severity: {
23
+ type: 'string',
24
+ enum: ['critical', 'moderate', 'suggestion'],
25
+ description: 'Issue severity level',
26
+ },
27
+ file: { type: 'string', description: 'File path' },
28
+ line: { type: 'integer', description: 'Line number' },
29
+ title: { type: 'string', description: 'Issue title' },
30
+ description: { type: 'string', description: 'Detailed description' },
31
+ suggestion: { type: 'string', description: 'Suggested fix' },
32
+ },
33
+ required: ['severity', 'file', 'title', 'description'],
34
+ },
35
+ },
36
+ score: {
37
+ type: 'integer',
38
+ minimum: 0,
39
+ maximum: 100,
40
+ description: 'Overall code quality score',
41
+ },
42
+ approved: { type: 'boolean', description: 'Whether the code is approved' },
43
+ },
44
+ required: ['summary', 'issues', 'score', 'approved'],
45
+ },
46
+
47
+ 'design-result': {
48
+ type: 'object',
49
+ properties: {
50
+ mockups: {
51
+ type: 'array',
52
+ items: {
53
+ type: 'object',
54
+ properties: {
55
+ name: { type: 'string', description: 'Mockup name' },
56
+ description: { type: 'string', description: 'Mockup description' },
57
+ imageUrl: { type: 'string', description: 'Generated image URL' },
58
+ components: {
59
+ type: 'array',
60
+ items: { type: 'string' },
61
+ description: 'UI components used',
62
+ },
63
+ },
64
+ required: ['name', 'description'],
65
+ },
66
+ },
67
+ rationale: { type: 'string', description: 'Design rationale' },
68
+ alternatives: {
69
+ type: 'array',
70
+ items: {
71
+ type: 'object',
72
+ properties: {
73
+ name: { type: 'string' },
74
+ description: { type: 'string' },
75
+ tradeoffs: { type: 'string' },
76
+ },
77
+ },
78
+ description: 'Alternative design approaches',
79
+ },
80
+ },
81
+ required: ['mockups', 'rationale'],
82
+ },
83
+
84
+ 'code-result': {
85
+ type: 'object',
86
+ properties: {
87
+ files: {
88
+ type: 'array',
89
+ items: {
90
+ type: 'object',
91
+ properties: {
92
+ path: { type: 'string', description: 'File path' },
93
+ content: { type: 'string', description: 'File content' },
94
+ action: {
95
+ type: 'string',
96
+ enum: ['create', 'modify', 'delete'],
97
+ description: 'Action to take',
98
+ },
99
+ },
100
+ required: ['path', 'content', 'action'],
101
+ },
102
+ },
103
+ explanation: { type: 'string', description: 'Explanation of changes' },
104
+ tests: {
105
+ type: 'array',
106
+ items: {
107
+ type: 'object',
108
+ properties: {
109
+ name: { type: 'string' },
110
+ description: { type: 'string' },
111
+ },
112
+ },
113
+ description: 'Suggested tests',
114
+ },
115
+ },
116
+ required: ['files', 'explanation'],
117
+ },
118
+ };
119
+
120
+ /**
121
+ * Load a schema from file
122
+ * @param {string} filePath - Path to schema file
123
+ * @returns {Promise<Object>} Parsed schema
124
+ */
125
+ export async function loadSchema(filePath) {
126
+ const content = await fs.readFile(filePath, 'utf8');
127
+ return JSON.parse(content);
128
+ }
129
+
130
+ /**
131
+ * Validate output against a schema
132
+ * @param {any} data - Data to validate
133
+ * @param {Object} schema - JSON schema
134
+ * @returns {Object} Validation result { valid, errors }
135
+ */
136
+ export function validateOutput(data, schema) {
137
+ const errors = [];
138
+
139
+ function validate(value, schemaNode, path = '') {
140
+ if (!schemaNode) return;
141
+
142
+ // Type validation
143
+ if (schemaNode.type) {
144
+ const actualType = Array.isArray(value) ? 'array' : typeof value;
145
+ const expectedType = schemaNode.type;
146
+
147
+ // Handle integer as number
148
+ if (expectedType === 'integer') {
149
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
150
+ errors.push(`${path}: expected integer, got ${actualType}`);
151
+ return;
152
+ }
153
+ } else if (expectedType !== actualType) {
154
+ errors.push(`${path}: expected ${expectedType}, got ${actualType}`);
155
+ return;
156
+ }
157
+ }
158
+
159
+ // Enum validation
160
+ if (schemaNode.enum && !schemaNode.enum.includes(value)) {
161
+ errors.push(`${path}: value must be one of: ${schemaNode.enum.join(', ')}`);
162
+ }
163
+
164
+ // Number constraints
165
+ if (typeof value === 'number') {
166
+ if (schemaNode.minimum !== undefined && value < schemaNode.minimum) {
167
+ errors.push(`${path}: value must be >= ${schemaNode.minimum}`);
168
+ }
169
+ if (schemaNode.maximum !== undefined && value > schemaNode.maximum) {
170
+ errors.push(`${path}: value must be <= ${schemaNode.maximum}`);
171
+ }
172
+ }
173
+
174
+ // Object validation
175
+ if (schemaNode.type === 'object' && typeof value === 'object' && value !== null) {
176
+ // Required fields
177
+ if (schemaNode.required) {
178
+ for (const field of schemaNode.required) {
179
+ if (!(field in value)) {
180
+ errors.push(`${path}.${field}: required field missing`);
181
+ }
182
+ }
183
+ }
184
+
185
+ // Validate properties
186
+ if (schemaNode.properties) {
187
+ for (const [key, propSchema] of Object.entries(schemaNode.properties)) {
188
+ if (key in value) {
189
+ validate(value[key], propSchema, `${path}.${key}`);
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // Array validation
196
+ if (schemaNode.type === 'array' && Array.isArray(value)) {
197
+ if (schemaNode.items) {
198
+ value.forEach((item, index) => {
199
+ validate(item, schemaNode.items, `${path}[${index}]`);
200
+ });
201
+ }
202
+ }
203
+ }
204
+
205
+ validate(data, schema, 'root');
206
+
207
+ return {
208
+ valid: errors.length === 0,
209
+ errors,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Convert schema to human-readable prompt instructions
215
+ * @param {Object} schema - JSON schema
216
+ * @returns {string} Text instructions
217
+ */
218
+ export function schemaToPromptInstructions(schema) {
219
+ const lines = ['Your response must be valid JSON matching this structure:'];
220
+
221
+ function describeSchema(node, indent = 0) {
222
+ const prefix = ' '.repeat(indent);
223
+
224
+ if (node.type === 'object' && node.properties) {
225
+ lines.push(`${prefix}{`);
226
+
227
+ for (const [key, prop] of Object.entries(node.properties)) {
228
+ const required = node.required?.includes(key) ? ' (required)' : '';
229
+ const type = prop.type || 'any';
230
+
231
+ if (prop.enum) {
232
+ lines.push(`${prefix} "${key}": one of [${prop.enum.join(', ')}]${required}`);
233
+ } else if (type === 'object' && prop.properties) {
234
+ lines.push(`${prefix} "${key}": {${required}`);
235
+ describeSchema(prop, indent + 2);
236
+ lines.push(`${prefix} }`);
237
+ } else if (type === 'array') {
238
+ lines.push(`${prefix} "${key}": array of ${prop.items?.type || 'items'}${required}`);
239
+ } else {
240
+ lines.push(`${prefix} "${key}": ${type}${required}`);
241
+ }
242
+ }
243
+
244
+ lines.push(`${prefix}}`);
245
+ }
246
+ }
247
+
248
+ describeSchema(schema);
249
+
250
+ return lines.join('\n');
251
+ }
252
+
253
+ /**
254
+ * Build a prompt that includes schema instructions
255
+ * @param {string} prompt - Original prompt
256
+ * @param {Object} schema - JSON schema
257
+ * @returns {string} Enhanced prompt
258
+ */
259
+ export function buildPromptWithSchema(prompt, schema) {
260
+ if (!schema) return prompt;
261
+
262
+ const instructions = schemaToPromptInstructions(schema);
263
+
264
+ return `${prompt}
265
+
266
+ ${instructions}
267
+
268
+ Respond ONLY with valid JSON matching the above structure.`;
269
+ }