@open-mercato/ai-assistant 0.4.9-develop-de525127d6 → 0.4.9-develop-31d1a87765

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 (41) hide show
  1. package/AGENTS.md +78 -37
  2. package/dist/modules/ai_assistant/api/chat/route.js +104 -13
  3. package/dist/modules/ai_assistant/api/chat/route.js.map +2 -2
  4. package/dist/modules/ai_assistant/lib/api-endpoint-index.js +97 -0
  5. package/dist/modules/ai_assistant/lib/api-endpoint-index.js.map +2 -2
  6. package/dist/modules/ai_assistant/lib/codemode-tools.js +610 -0
  7. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +7 -0
  8. package/dist/modules/ai_assistant/lib/http-server.js +65 -7
  9. package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
  10. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +16 -1
  11. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
  12. package/dist/modules/ai_assistant/lib/mcp-server.js +17 -0
  13. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  14. package/dist/modules/ai_assistant/lib/opencode-handlers.js +32 -0
  15. package/dist/modules/ai_assistant/lib/opencode-handlers.js.map +2 -2
  16. package/dist/modules/ai_assistant/lib/sandbox.js +124 -0
  17. package/dist/modules/ai_assistant/lib/sandbox.js.map +7 -0
  18. package/dist/modules/ai_assistant/lib/session-memory.js +103 -0
  19. package/dist/modules/ai_assistant/lib/session-memory.js.map +7 -0
  20. package/dist/modules/ai_assistant/lib/tool-loader.js +4 -114
  21. package/dist/modules/ai_assistant/lib/tool-loader.js.map +3 -3
  22. package/dist/modules/ai_assistant/lib/truncate.js +26 -0
  23. package/dist/modules/ai_assistant/lib/truncate.js.map +7 -0
  24. package/jest.config.cjs +23 -0
  25. package/package.json +6 -5
  26. package/src/modules/ai_assistant/api/chat/route.ts +110 -14
  27. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +129 -0
  28. package/src/modules/ai_assistant/lib/__tests__/sandbox.test.ts +642 -0
  29. package/src/modules/ai_assistant/lib/__tests__/session-memory.test.ts +82 -0
  30. package/src/modules/ai_assistant/lib/__tests__/truncate.test.ts +76 -0
  31. package/src/modules/ai_assistant/lib/api-endpoint-index.ts +143 -0
  32. package/src/modules/ai_assistant/lib/codemode-tools.ts +864 -0
  33. package/src/modules/ai_assistant/lib/http-server.ts +86 -9
  34. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +19 -0
  35. package/src/modules/ai_assistant/lib/mcp-server.ts +21 -0
  36. package/src/modules/ai_assistant/lib/opencode-handlers.ts +40 -0
  37. package/src/modules/ai_assistant/lib/sandbox.ts +192 -0
  38. package/src/modules/ai_assistant/lib/session-memory.ts +174 -0
  39. package/src/modules/ai_assistant/lib/tool-loader.ts +11 -145
  40. package/src/modules/ai_assistant/lib/truncate.ts +45 -0
  41. package/src/modules/ai_assistant/lib/types.ts +2 -0
@@ -0,0 +1,864 @@
1
+ /**
2
+ * Code Mode Tools
3
+ *
4
+ * Two meta-tools that replace all individual API/schema/module tools:
5
+ * - search: Query the OpenAPI spec + entity graph programmatically
6
+ * - execute: Make API calls via a sandboxed api.request() wrapper
7
+ *
8
+ * The AI writes JavaScript that runs in a node:vm sandbox with injected globals.
9
+ */
10
+
11
+ import { z } from 'zod'
12
+ import { registerMcpTool } from './tool-registry'
13
+ import type { McpToolContext } from './types'
14
+ import { createSandbox } from './sandbox'
15
+ import { truncateResult } from './truncate'
16
+ import { getRawOpenApiSpec } from './api-endpoint-index'
17
+ import {
18
+ getCachedEntityGraph,
19
+ inferModuleFromEntity,
20
+ type EntityGraph,
21
+ } from './entity-graph'
22
+ import {
23
+ lookupSearchCache,
24
+ storeSearchResult,
25
+ buildMemoryContext,
26
+ buildSearchLabel,
27
+ incrementToolCallCount,
28
+ } from './session-memory'
29
+
30
+ /**
31
+ * Cached spec object combining OpenAPI paths + entity schemas.
32
+ */
33
+ let cachedCodeModeSpec: Record<string, unknown> | null = null
34
+
35
+ /**
36
+ * Cached TypeScript type stubs for common CRUD endpoints.
37
+ * Generated once at startup from the OpenAPI spec.
38
+ */
39
+ let cachedCommonTypes: string | null = null
40
+
41
+ /**
42
+ * Build the merged spec object for the search tool.
43
+ */
44
+ async function getCodeModeSpec(): Promise<Record<string, unknown>> {
45
+ if (cachedCodeModeSpec) return cachedCodeModeSpec
46
+
47
+ const rawSpec = await getRawOpenApiSpec()
48
+ const graph = getCachedEntityGraph()
49
+
50
+ const paths = (rawSpec?.paths ?? {}) as Record<string, Record<string, unknown>>
51
+ const entitySchemas = graph ? buildEntitySchemas(graph) : []
52
+
53
+ const spec: Record<string, unknown> = {
54
+ paths,
55
+ info: rawSpec?.info,
56
+ components: rawSpec?.components,
57
+ entitySchemas,
58
+ }
59
+
60
+ // --- Helper functions injected into sandbox ---
61
+
62
+ /**
63
+ * spec.findEndpoints(keyword) — find all endpoints matching a keyword.
64
+ * Returns compact list: [{ path, methods }]
65
+ */
66
+ spec.findEndpoints = (keyword: string) => {
67
+ const kw = keyword.toLowerCase()
68
+ return Object.entries(paths)
69
+ .filter(([path]) => path.toLowerCase().includes(kw))
70
+ .map(([path, methods]) => ({
71
+ path,
72
+ methods: Object.keys(methods).filter((m) => m !== 'parameters'),
73
+ }))
74
+ }
75
+
76
+ /**
77
+ * spec.describeEndpoint(path, method) — compact endpoint profile with working example.
78
+ * Returns: { path, method, summary, requiredFields, optionalFields, nestedCollections, example, relatedEndpoints, relatedEntity }
79
+ * For full schema access, use: spec.paths[path][method].requestBody
80
+ */
81
+ spec.describeEndpoint = (path: string, method: string) => {
82
+ const pathObj = paths[path] as Record<string, unknown> | undefined
83
+ if (!pathObj) return null
84
+
85
+ const endpoint = pathObj[method.toLowerCase()] as Record<string, unknown> | undefined
86
+ if (!endpoint) return null
87
+
88
+ // Extract requestBody JSON Schema
89
+ const bodySchema = extractRequestBodySchema(endpoint)
90
+ const bodyProps = (bodySchema?.properties ?? {}) as Record<string, Record<string, unknown>>
91
+ const bodyRequired = (bodySchema?.required ?? []) as string[]
92
+
93
+ // Split fields into required (with types) vs optional (names only)
94
+ const requiredFields: Array<{ name: string; type: string; format?: string }> = []
95
+ const optionalFields: string[] = []
96
+ const nestedCollections: Array<{
97
+ field: string
98
+ type: string
99
+ requiredFields: Array<{ name: string; type: string }>
100
+ commonFields: string[]
101
+ }> = []
102
+
103
+ for (const [name, prop] of Object.entries(bodyProps)) {
104
+ const propType = (prop.type as string) || 'string'
105
+
106
+ // Detect nested array collections (e.g. lines, items, addresses)
107
+ if (propType === 'array' && prop.items && (prop.items as Record<string, unknown>).type === 'object') {
108
+ const itemSchema = prop.items as Record<string, unknown>
109
+ const itemProps = (itemSchema.properties ?? {}) as Record<string, Record<string, unknown>>
110
+ const itemRequired = (itemSchema.required ?? []) as string[]
111
+
112
+ const nestedRequired = itemRequired.map((n) => ({
113
+ name: n,
114
+ type: ((itemProps[n]?.type as string) || 'string'),
115
+ }))
116
+
117
+ // Common fields: first few non-required fields that are likely user-provided
118
+ const nestedOptional = Object.keys(itemProps).filter((n) => !itemRequired.includes(n))
119
+ const commonFields = nestedOptional.slice(0, 6)
120
+
121
+ nestedCollections.push({
122
+ field: name,
123
+ type: 'array',
124
+ requiredFields: nestedRequired,
125
+ commonFields,
126
+ })
127
+ continue
128
+ }
129
+
130
+ if (bodyRequired.includes(name)) {
131
+ const field: { name: string; type: string; format?: string } = { name, type: propType }
132
+ if (prop.format) field.format = prop.format as string
133
+ requiredFields.push(field)
134
+ } else {
135
+ optionalFields.push(name)
136
+ }
137
+ }
138
+
139
+ // Generate minimal working example from required fields + nested collections
140
+ const example: Record<string, unknown> = {}
141
+ for (const field of requiredFields) {
142
+ example[field.name] = generatePlaceholder(field.type, field.format)
143
+ }
144
+ for (const collection of nestedCollections) {
145
+ const itemExample: Record<string, unknown> = {}
146
+ for (const field of collection.requiredFields) {
147
+ itemExample[field.name] = generatePlaceholder(field.type)
148
+ }
149
+ // Add first 2 common fields to the example
150
+ for (const name of collection.commonFields.slice(0, 2)) {
151
+ itemExample[name] = '<value>'
152
+ }
153
+ example[collection.field] = [itemExample]
154
+ }
155
+
156
+ // Find related endpoints sharing the same module prefix
157
+ const segments = path.replace('/api/', '').split('/')
158
+ const moduleSegment = segments[0]
159
+ const resourceName = segments[1] || segments[0]
160
+ const modulePrefix = `/api/${moduleSegment}/`
161
+ const relatedEndpoints = Object.entries(paths)
162
+ .filter(([p]) => p.startsWith(modulePrefix) && p !== path && !p.includes('{'))
163
+ .map(([p, methods]) => ({
164
+ path: p,
165
+ methods: Object.keys(methods as Record<string, unknown>).filter((m) => m !== 'parameters'),
166
+ }))
167
+ .slice(0, 8)
168
+
169
+ // Compact entity: className + relationship summary
170
+ const resourceNorm = resourceName.replace(/-/g, '_')
171
+ const resourceSingular = resourceNorm.endsWith('s') ? resourceNorm.slice(0, -1) : resourceNorm
172
+ const moduleSingular = moduleSegment.endsWith('s') ? moduleSegment.slice(0, -1) : moduleSegment
173
+ const prefixedTable = `${moduleSingular}_${resourceNorm}`
174
+
175
+ const entity = entitySchemas.find((e: Record<string, unknown>) => {
176
+ const table = ((e.tableName as string) || '').toLowerCase()
177
+ const cls = ((e.className as string) || '').toLowerCase()
178
+ const mod = ((e.module as string) || '').toLowerCase()
179
+ if (table === resourceNorm || table === prefixedTable) return true
180
+ if (cls.includes(moduleSingular) && cls.includes(resourceSingular)) return true
181
+ if (mod === moduleSegment && cls.includes(resourceSingular)) return true
182
+ if (cls === resourceSingular || cls.includes(resourceSingular)) return true
183
+ return false
184
+ }) || null
185
+
186
+ let relatedEntity: string | null = null
187
+ if (entity) {
188
+ const ent = entity as Record<string, unknown>
189
+ const rels = (ent.relationships as Array<{ relationship: string; target: string }>) || []
190
+ const relSummary = rels.map((r) => `${r.relationship}: ${r.target}`).join(', ')
191
+ relatedEntity = `${ent.className}${relSummary ? ` (${relSummary})` : ''}`
192
+ }
193
+
194
+ // GET endpoints: include query parameters compactly
195
+ const parameters = method.toLowerCase() === 'get'
196
+ ? (endpoint.parameters as Array<Record<string, unknown>> || [])
197
+ .filter((p) => p.in === 'query')
198
+ .map((p) => p.name as string)
199
+ : undefined
200
+
201
+ return {
202
+ path,
203
+ method: method.toUpperCase(),
204
+ summary: endpoint.summary || endpoint.description,
205
+ ...(parameters && parameters.length > 0 ? { queryParams: parameters } : {}),
206
+ ...(requiredFields.length > 0 ? { requiredFields } : {}),
207
+ ...(optionalFields.length > 0 ? { optionalFields } : {}),
208
+ ...(nestedCollections.length > 0 ? { nestedCollections } : {}),
209
+ ...(Object.keys(example).length > 0 ? { example } : {}),
210
+ ...(relatedEndpoints.length > 0 ? { relatedEndpoints } : {}),
211
+ relatedEntity,
212
+ }
213
+ }
214
+
215
+ /**
216
+ * spec.describeEntity(keyword) — find entity by keyword and return its full schema.
217
+ * Returns: { className, tableName, module, fields, relationships }
218
+ */
219
+ spec.describeEntity = (keyword: string) => {
220
+ const kw = keyword.toLowerCase()
221
+ return entitySchemas.find((e: Record<string, unknown>) => {
222
+ const cls = (e.className as string || '').toLowerCase()
223
+ const table = (e.tableName as string || '').toLowerCase()
224
+ return cls.includes(kw) || table.includes(kw)
225
+ }) || null
226
+ }
227
+
228
+ cachedCodeModeSpec = spec
229
+ return spec
230
+ }
231
+
232
+ /**
233
+ * Extract the JSON Schema from an OpenAPI endpoint's requestBody.
234
+ * Handles the common `content['application/json'].schema` path.
235
+ */
236
+ function extractRequestBodySchema(
237
+ endpoint: Record<string, unknown>
238
+ ): Record<string, unknown> | null {
239
+ const requestBody = endpoint.requestBody as Record<string, unknown> | undefined
240
+ if (!requestBody) return null
241
+
242
+ const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
243
+ if (!content) return null
244
+
245
+ const jsonContent = content['application/json']
246
+ if (!jsonContent) return null
247
+
248
+ return (jsonContent.schema as Record<string, unknown>) || null
249
+ }
250
+
251
+ /**
252
+ * Generate a placeholder value for a given JSON Schema type.
253
+ */
254
+ function generatePlaceholder(type: string, format?: string): unknown {
255
+ if (format === 'uuid' || format === 'objectId') return '<uuid>'
256
+ if (format === 'date-time' || format === 'date') return '<date>'
257
+ if (format === 'email') return '<email>'
258
+ switch (type) {
259
+ case 'string': return '<string>'
260
+ case 'number':
261
+ case 'integer': return 0
262
+ case 'boolean': return false
263
+ case 'array': return []
264
+ default: return '<value>'
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Common CRUD endpoints to pre-generate types for.
270
+ * These are the endpoints the agent uses most and where debug spirals happen.
271
+ */
272
+ const COMMON_ENDPOINTS: Array<{ path: string; method: string; typeName: string }> = [
273
+ { path: '/api/sales/quotes', method: 'post', typeName: 'CreateQuote' },
274
+ { path: '/api/sales/orders', method: 'post', typeName: 'CreateOrder' },
275
+ { path: '/api/sales/invoices', method: 'post', typeName: 'CreateInvoice' },
276
+ { path: '/api/customers/companies', method: 'post', typeName: 'CreateCompany' },
277
+ { path: '/api/customers/people', method: 'post', typeName: 'CreatePerson' },
278
+ { path: '/api/customers/deals', method: 'post', typeName: 'CreateDeal' },
279
+ { path: '/api/catalog/products', method: 'post', typeName: 'CreateProduct' },
280
+ { path: '/api/customers/companies', method: 'put', typeName: 'UpdateCompany' },
281
+ { path: '/api/customers/people', method: 'put', typeName: 'UpdatePerson' },
282
+ { path: '/api/sales/quotes', method: 'put', typeName: 'UpdateQuote' },
283
+ ]
284
+
285
+ /**
286
+ * Generate TypeScript-like type stubs from the OpenAPI spec for common endpoints.
287
+ * This runs once at startup and injects types into the execute tool description
288
+ * so the LLM sees the correct payload shape without needing to call describeEndpoint.
289
+ */
290
+ async function generateCommonTypes(): Promise<string> {
291
+ if (cachedCommonTypes) return cachedCommonTypes
292
+
293
+ const rawSpec = await getRawOpenApiSpec()
294
+ if (!rawSpec?.paths) {
295
+ cachedCommonTypes = ''
296
+ return ''
297
+ }
298
+
299
+ const paths = rawSpec.paths as Record<string, Record<string, unknown>>
300
+ const typeLines: string[] = ['Available types for api.request() body:\n']
301
+
302
+ for (const { path, method, typeName } of COMMON_ENDPOINTS) {
303
+ const pathObj = paths[path] as Record<string, unknown> | undefined
304
+ if (!pathObj) continue
305
+
306
+ const endpoint = pathObj[method] as Record<string, unknown> | undefined
307
+ if (!endpoint) continue
308
+
309
+ const bodySchema = extractRequestBodySchema(endpoint)
310
+ if (!bodySchema?.properties) continue
311
+
312
+ const typeStr = schemaToTypeString(
313
+ typeName,
314
+ bodySchema,
315
+ `${method.toUpperCase()} ${path}`,
316
+ )
317
+ if (typeStr) typeLines.push(typeStr)
318
+ }
319
+
320
+ if (typeLines.length <= 1) {
321
+ cachedCommonTypes = ''
322
+ return ''
323
+ }
324
+
325
+ cachedCommonTypes = typeLines.join('\n')
326
+ console.error(`[Code Mode] Generated ${typeLines.length - 1} common type stubs`)
327
+ return cachedCommonTypes
328
+ }
329
+
330
+ /**
331
+ * Convert a JSON Schema object to a compact TypeScript-like type string.
332
+ * Produces a single-line or multi-line type declaration the LLM can use directly.
333
+ */
334
+ function schemaToTypeString(
335
+ typeName: string,
336
+ schema: Record<string, unknown>,
337
+ comment: string,
338
+ ): string | null {
339
+ const props = schema.properties as Record<string, Record<string, unknown>> | undefined
340
+ if (!props) return null
341
+
342
+ const required = new Set((schema.required as string[]) || [])
343
+
344
+ // Skip internal fields that the sandbox injects automatically
345
+ const skipFields = new Set(['tenantId', 'organizationId'])
346
+
347
+ const fields: string[] = []
348
+ const nestedTypes: string[] = []
349
+
350
+ for (const [name, prop] of Object.entries(props)) {
351
+ if (skipFields.has(name)) continue
352
+ if (!prop || typeof prop !== 'object') continue
353
+
354
+ const isRequired = required.has(name)
355
+ const optMark = isRequired ? '' : '?'
356
+
357
+ // Detect nested array of objects → extract as separate type
358
+ if (
359
+ prop.type === 'array' &&
360
+ prop.items &&
361
+ (prop.items as Record<string, unknown>).type === 'object'
362
+ ) {
363
+ const itemTypeName = `${typeName}${capitalize(singularize(name))}`
364
+ const itemSchema = prop.items as Record<string, unknown>
365
+ const nestedType = schemaToTypeString(itemTypeName, itemSchema, '')
366
+ if (nestedType) nestedTypes.push(nestedType)
367
+ fields.push(`${name}${optMark}: ${itemTypeName}[]`)
368
+ continue
369
+ }
370
+
371
+ const propType = resolvePropertyType(prop)
372
+ fields.push(`${name}${optMark}: ${propType}`)
373
+ }
374
+
375
+ if (fields.length === 0) return null
376
+
377
+ const commentLine = comment ? `// ${comment}\n` : ''
378
+ const nested = nestedTypes.length > 0 ? nestedTypes.join('\n') + '\n' : ''
379
+ return `${nested}${commentLine}type ${typeName} = { ${fields.join('; ')} }`
380
+ }
381
+
382
+ /**
383
+ * Resolve a JSON Schema property to a compact TypeScript type string.
384
+ */
385
+ function resolvePropertyType(prop: Record<string, unknown>): string {
386
+ // Handle anyOf (nullable types)
387
+ if (prop.anyOf && Array.isArray(prop.anyOf)) {
388
+ const variants = (prop.anyOf as Array<Record<string, unknown> | null>).filter(
389
+ (s): s is Record<string, unknown> => s != null,
390
+ )
391
+ const nonNull = variants.filter((s) => s.type !== 'null')
392
+ if (nonNull.length === 1) {
393
+ return resolvePropertyType(nonNull[0]) + ' | null'
394
+ }
395
+ if (nonNull.length > 1) {
396
+ return nonNull.map((s) => resolvePropertyType(s)).join(' | ')
397
+ }
398
+ }
399
+
400
+ // Handle enum
401
+ if (prop.enum && Array.isArray(prop.enum)) {
402
+ return (prop.enum as string[]).map((v) => `'${v}'`).join(' | ')
403
+ }
404
+
405
+ const type = prop.type as string
406
+ const format = prop.format as string | undefined
407
+
408
+ if (type === 'array') {
409
+ const items = prop.items as Record<string, unknown> | undefined
410
+ if (items) return `${resolvePropertyType(items)}[]`
411
+ return 'unknown[]'
412
+ }
413
+
414
+ if (type === 'object') return 'object'
415
+
416
+ if (format === 'uuid') return 'string /*uuid*/'
417
+ if (format === 'date-time') return 'string /*ISO date*/'
418
+ if (format === 'date') return 'string /*date*/'
419
+ if (format === 'email') return 'string /*email*/'
420
+
421
+ switch (type) {
422
+ case 'string': return 'string'
423
+ case 'number':
424
+ case 'integer': return 'number'
425
+ case 'boolean': return 'boolean'
426
+ default: return 'unknown'
427
+ }
428
+ }
429
+
430
+ function capitalize(s: string): string {
431
+ return s.charAt(0).toUpperCase() + s.slice(1)
432
+ }
433
+
434
+ function singularize(s: string): string {
435
+ if (s.endsWith('ies')) return s.slice(0, -3) + 'y'
436
+ if (s.endsWith('ses')) return s.slice(0, -2)
437
+ if (s.endsWith('s') && !s.endsWith('ss')) return s.slice(0, -1)
438
+ return s
439
+ }
440
+
441
+ /**
442
+ * Format a 400 API error response into a human-readable fix instruction.
443
+ * Parses Zod-style validation errors and produces a concise message the LLM can act on.
444
+ */
445
+ function formatValidationError(data: unknown): string {
446
+ if (!data || typeof data !== 'object') {
447
+ return `Validation error: ${JSON.stringify(data)}`
448
+ }
449
+
450
+ // Raw Zod v4 array format: [{ expected, code, path, message }]
451
+ if (Array.isArray(data)) {
452
+ const issues = data as Array<Record<string, unknown>>
453
+ const parts = issues.slice(0, 5).map((issue) => {
454
+ const path = Array.isArray(issue.path) ? issue.path.join('.') : ''
455
+ const msg = issue.message as string || `expected ${issue.expected}` || issue.code as string || 'invalid'
456
+ return path ? `${path}: ${msg}` : msg
457
+ })
458
+ if (parts.length > 0) {
459
+ return `Validation failed — ${parts.join('; ')}. Fix the listed fields and retry.`
460
+ }
461
+ }
462
+
463
+ const obj = data as Record<string, unknown>
464
+
465
+ // Zod v4 flat format: { fieldErrors: { field: [messages] }, formErrors: [messages] }
466
+ if (obj.fieldErrors && typeof obj.fieldErrors === 'object') {
467
+ const fieldErrors = obj.fieldErrors as Record<string, string[]>
468
+ const parts: string[] = []
469
+ for (const [field, messages] of Object.entries(fieldErrors)) {
470
+ if (Array.isArray(messages) && messages.length > 0) {
471
+ parts.push(`${field}: ${messages[0]}`)
472
+ }
473
+ }
474
+ const formErrors = obj.formErrors as string[] | undefined
475
+ if (Array.isArray(formErrors) && formErrors.length > 0) {
476
+ parts.push(formErrors[0])
477
+ }
478
+ if (parts.length > 0) {
479
+ return `Validation failed — ${parts.join('; ')}. Fix the listed fields and retry.`
480
+ }
481
+ }
482
+
483
+ // Zod v3 format: { issues: [{ path: [...], message, code }] }
484
+ if (obj.issues && Array.isArray(obj.issues)) {
485
+ const issues = obj.issues as Array<Record<string, unknown>>
486
+ const parts = issues.slice(0, 5).map((issue) => {
487
+ const path = Array.isArray(issue.path) ? issue.path.join('.') : ''
488
+ const msg = issue.message as string || issue.code as string || 'invalid'
489
+ return path ? `${path}: ${msg}` : msg
490
+ })
491
+ return `Validation failed — ${parts.join('; ')}. Fix the listed fields and retry.`
492
+ }
493
+
494
+ // Our API error format: { error: string, details: ... }
495
+ if (obj.error && typeof obj.error === 'string') {
496
+ const details = obj.details
497
+ if (details && typeof details === 'object') {
498
+ return formatValidationError(details)
499
+ }
500
+ return obj.error
501
+ }
502
+
503
+ // Generic: { message: string }
504
+ if (obj.message && typeof obj.message === 'string') {
505
+ return obj.message
506
+ }
507
+
508
+ // Fallback: compact JSON
509
+ const json = JSON.stringify(data)
510
+ if (json.length > 500) {
511
+ return `Validation error (truncated): ${json.slice(0, 500)}...`
512
+ }
513
+ return `Validation error: ${json}`
514
+ }
515
+
516
+ /**
517
+ * Build entity schema array from the entity graph.
518
+ * Same structure as buildEntityResult in entity-graph-tools.ts.
519
+ */
520
+ function buildEntitySchemas(graph: EntityGraph) {
521
+ return graph.nodes.map((node) => {
522
+ const relationships = graph.edges
523
+ .filter((edge) => edge.source === node.className)
524
+ .map((edge) => ({
525
+ relationship: edge.relationship,
526
+ target: edge.target,
527
+ property: edge.property,
528
+ nullable: edge.nullable,
529
+ }))
530
+
531
+ return {
532
+ className: node.className,
533
+ tableName: node.tableName,
534
+ module: inferModuleFromEntity(node.className, node.tableName),
535
+ fields: node.properties,
536
+ relationships,
537
+ }
538
+ })
539
+ }
540
+
541
+ /**
542
+ * Detect mutation HTTP methods in code via static analysis.
543
+ * Returns which methods were found (POST, PUT, PATCH, DELETE).
544
+ */
545
+ function detectMutationInCode(code: string): { hasMutation: boolean; methods: string[] } {
546
+ const methods: string[] = []
547
+ const pattern = /method:\s*['"](\w+)['"]/gi
548
+ let match
549
+ while ((match = pattern.exec(code)) !== null) {
550
+ const method = match[1].toUpperCase()
551
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
552
+ methods.push(method)
553
+ }
554
+ }
555
+ return { hasMutation: methods.length > 0, methods }
556
+ }
557
+
558
+ /**
559
+ * Load and register the two Code Mode tools.
560
+ * Generates TypeScript type stubs for common endpoints at startup.
561
+ * @returns Number of tools registered (always 2)
562
+ */
563
+ export async function loadCodeModeTools(): Promise<number> {
564
+ const commonTypes = await generateCommonTypes()
565
+ registerSearchTool()
566
+ registerExecuteTool(commonTypes)
567
+ return 2
568
+ }
569
+
570
+ /**
571
+ * search — Query the OpenAPI spec and entity graph programmatically.
572
+ */
573
+ function registerSearchTool(): void {
574
+ registerMcpTool(
575
+ {
576
+ name: 'search',
577
+ description: `Query the OpenAPI spec and entity schemas. READ-ONLY, no side effects.
578
+ Globals: spec.findEndpoints(keyword), spec.describeEndpoint(path, method), spec.describeEntity(keyword), spec.paths, spec.entitySchemas.
579
+ Use BEFORE execute to learn endpoint schemas for CREATE/UPDATE. Skip for common paths (companies, people, orders, quotes, products).`,
580
+ inputSchema: z.object({
581
+ code: z
582
+ .string()
583
+ .describe(
584
+ 'An async arrow function that queries spec, e.g. async () => spec.paths["/api/customers/companies"]'
585
+ ),
586
+ }),
587
+ requiredFeatures: [],
588
+ handler: async (input: { code: string }, ctx: McpToolContext) => {
589
+ const codePreview = input.code.slice(0, 120).replace(/\n/g, ' ')
590
+ console.error(`[AI Usage] search: code="${codePreview}${input.code.length > 120 ? '...' : ''}"`)
591
+
592
+ // Check session memory for cached result
593
+ if (ctx.sessionId) {
594
+ const cached = lookupSearchCache(ctx.sessionId, input.code)
595
+ if (cached) {
596
+ console.error(`[AI Usage] search: CACHE HIT (label="${cached.label}")`)
597
+ const memoryContext = buildMemoryContext(ctx.sessionId)
598
+ return {
599
+ success: true,
600
+ result: cached.result,
601
+ fromCache: true,
602
+ _memoryContext: memoryContext,
603
+ }
604
+ }
605
+
606
+ // Enforce tool call limit
607
+ const { count, exceeded } = incrementToolCallCount(ctx.sessionId)
608
+ if (exceeded) {
609
+ console.error(`[AI Usage] search: TOOL CALL LIMIT EXCEEDED (count=${count})`)
610
+ return {
611
+ success: false,
612
+ error: 'Tool call limit exceeded. Summarize what you know and respond to the user.',
613
+ }
614
+ }
615
+ }
616
+
617
+ const spec = await getCodeModeSpec()
618
+ const sandbox = createSandbox({ spec })
619
+ const result = await sandbox.execute(input.code)
620
+
621
+ if (result.error) {
622
+ console.error(`[AI Usage] search: ERROR in ${result.durationMs}ms — ${result.error}`)
623
+ return {
624
+ success: false,
625
+ error: result.error,
626
+ logs: result.logs,
627
+ durationMs: result.durationMs,
628
+ }
629
+ }
630
+
631
+ const truncated = truncateResult(result.result)
632
+ console.error(`[AI Usage] search: OK in ${result.durationMs}ms — ${truncated.length} chars`)
633
+
634
+ // Store in session memory
635
+ if (ctx.sessionId) {
636
+ const label = buildSearchLabel(input.code)
637
+ storeSearchResult(ctx.sessionId, input.code, truncated, label)
638
+ }
639
+
640
+ const memoryContext = ctx.sessionId ? buildMemoryContext(ctx.sessionId) : undefined
641
+ return {
642
+ success: true,
643
+ result: truncated,
644
+ logs: result.logs,
645
+ durationMs: result.durationMs,
646
+ _memoryContext: memoryContext,
647
+ }
648
+ },
649
+ },
650
+ { moduleId: 'codemode' }
651
+ )
652
+ }
653
+
654
+ /**
655
+ * execute — Run JavaScript that can make API calls via api.request().
656
+ */
657
+ function registerExecuteTool(commonTypes: string): void {
658
+ const typesBlock = commonTypes
659
+ ? `\n\n${commonTypes}`
660
+ : ''
661
+
662
+ registerMcpTool(
663
+ {
664
+ name: 'execute',
665
+ description: `Make API calls. Returns JSON.
666
+ Globals: api.request({ method, path, query?, body? }) → { success, statusCode, data }, context { tenantId, organizationId, userId }.
667
+ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection path with id in BODY. NEVER PUT/POST/DELETE unless user explicitly asked to change data. Before ANY write operation (POST/PUT/DELETE), you MUST use the AskUserQuestion tool to get explicit user confirmation. Do NOT just ask in text — use the tool so execution pauses until the user responds.${typesBlock}`,
668
+ inputSchema: z.object({
669
+ code: z
670
+ .string()
671
+ .describe(
672
+ 'Async arrow function. For reads: async () => api.request({ method: "GET", path: "/api/customers/companies" }). For updates: async () => api.request({ method: "PUT", path: "/api/customers/companies", body: { id: "<uuid>", name: "New Name" } }). id goes in BODY not URL.'
673
+ ),
674
+ }),
675
+ requiredFeatures: [], // ACL checked at API level
676
+ handler: async (input: { code: string }, ctx: McpToolContext) => {
677
+ const codePreview = input.code.slice(0, 120).replace(/\n/g, ' ')
678
+ console.error(`[AI Usage] execute: code="${codePreview}${input.code.length > 120 ? '...' : ''}" user=${ctx.userId || 'unknown'}`)
679
+
680
+ // Enforce tool call limit
681
+ if (ctx.sessionId) {
682
+ const { count, exceeded } = incrementToolCallCount(ctx.sessionId)
683
+ if (exceeded) {
684
+ console.error(`[AI Usage] execute: TOOL CALL LIMIT EXCEEDED (count=${count})`)
685
+ return {
686
+ success: false,
687
+ error: 'Tool call limit exceeded. Summarize what you know and respond to the user.',
688
+ }
689
+ }
690
+ }
691
+
692
+ // Detect mutations via static analysis — cap API calls for safety
693
+ const mutationInfo = detectMutationInCode(input.code)
694
+ const maxApiCalls = mutationInfo.hasMutation ? 20 : 50
695
+ if (mutationInfo.hasMutation) {
696
+ console.error(`[AI Usage] execute: MUTATION DETECTED (${mutationInfo.methods.join(',')}) — capping API calls to ${maxApiCalls}`)
697
+ }
698
+ let apiCallCount = 0
699
+
700
+ const apiRequestFn = createApiRequestFn(ctx, () => {
701
+ apiCallCount++
702
+ if (apiCallCount > maxApiCalls) {
703
+ throw new Error(`API call limit exceeded (max ${maxApiCalls})`)
704
+ }
705
+ })
706
+
707
+ const context = {
708
+ tenantId: ctx.tenantId,
709
+ organizationId: ctx.organizationId,
710
+ userId: ctx.userId,
711
+ }
712
+
713
+ const sandbox = createSandbox(
714
+ { api: { request: apiRequestFn }, context },
715
+ { maxApiCalls }
716
+ )
717
+
718
+ const result = await sandbox.execute(input.code)
719
+
720
+ if (result.error) {
721
+ console.error(`[AI Usage] execute: ERROR in ${result.durationMs}ms — apiCalls=${apiCallCount} — ${result.error}`)
722
+ return {
723
+ success: false,
724
+ error: result.error,
725
+ logs: result.logs,
726
+ durationMs: result.durationMs,
727
+ apiCallCount,
728
+ }
729
+ }
730
+
731
+ const truncated = truncateResult(result.result)
732
+ console.error(`[AI Usage] execute: OK in ${result.durationMs}ms — apiCalls=${apiCallCount} — ${truncated.length} chars`)
733
+
734
+ const memoryContext = ctx.sessionId ? buildMemoryContext(ctx.sessionId) : undefined
735
+ return {
736
+ success: true,
737
+ result: truncated,
738
+ logs: result.logs,
739
+ durationMs: result.durationMs,
740
+ apiCallCount,
741
+ _memoryContext: memoryContext,
742
+ }
743
+ },
744
+ },
745
+ { moduleId: 'codemode' }
746
+ )
747
+ }
748
+
749
+ /**
750
+ * Create the api.request() function for the execute sandbox.
751
+ * Replicates the authenticated API call logic from api-discovery-tools.ts.
752
+ */
753
+ function createApiRequestFn(
754
+ ctx: McpToolContext,
755
+ onCall: () => void
756
+ ): (params: {
757
+ method: string
758
+ path: string
759
+ query?: Record<string, string>
760
+ body?: Record<string, unknown>
761
+ }) => Promise<unknown> {
762
+ const baseUrl =
763
+ process.env.NEXT_PUBLIC_API_BASE_URL ||
764
+ process.env.NEXT_PUBLIC_APP_URL ||
765
+ process.env.APP_URL ||
766
+ 'http://localhost:3000'
767
+
768
+ return async (params) => {
769
+ onCall()
770
+
771
+ const { method, path, query, body } = params
772
+ const callStart = Date.now()
773
+
774
+ // Ensure path starts with /api
775
+ const apiPath = path.startsWith('/api') ? path : `/api${path}`
776
+ let url = `${baseUrl}${apiPath}`
777
+
778
+ // Build query parameters
779
+ const queryParams: Record<string, string> = { ...query }
780
+
781
+ if (method === 'GET') {
782
+ if (ctx.tenantId) queryParams.tenantId = ctx.tenantId
783
+ if (ctx.organizationId) queryParams.organizationId = ctx.organizationId
784
+ }
785
+
786
+ if (Object.keys(queryParams).length > 0) {
787
+ const separator = url.includes('?') ? '&' : '?'
788
+ url += separator + new URLSearchParams(queryParams).toString()
789
+ }
790
+
791
+ // Build request body with context injection
792
+ let requestBody: Record<string, unknown> | undefined
793
+ if (['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
794
+ requestBody = { ...body }
795
+ if (ctx.tenantId) requestBody.tenantId = ctx.tenantId
796
+ if (ctx.organizationId) requestBody.organizationId = ctx.organizationId
797
+ }
798
+
799
+ // Build headers
800
+ const headers: Record<string, string> = {
801
+ 'Content-Type': 'application/json',
802
+ }
803
+ if (ctx.apiKeySecret) headers['X-API-Key'] = ctx.apiKeySecret
804
+ if (ctx.tenantId) headers['X-Tenant-Id'] = ctx.tenantId
805
+ if (ctx.organizationId) headers['X-Organization-Id'] = ctx.organizationId
806
+
807
+ // Execute request using host fetch (not sandbox)
808
+ const response = await globalThis.fetch(url, {
809
+ method: method.toUpperCase(),
810
+ headers,
811
+ body: requestBody ? JSON.stringify(requestBody) : undefined,
812
+ })
813
+
814
+ const responseText = await response.text()
815
+ const data = tryParseJson(responseText)
816
+ const callDuration = Date.now() - callStart
817
+
818
+ if (!response.ok) {
819
+ console.error(`[AI Usage] api.request: ${method.toUpperCase()} ${apiPath} → ${response.status} in ${callDuration}ms`)
820
+
821
+ // Format 400 validation errors into a clear fix instruction for the LLM
822
+ if (response.status === 400) {
823
+ return {
824
+ success: false,
825
+ statusCode: 400,
826
+ error: formatValidationError(data),
827
+ }
828
+ }
829
+
830
+ return {
831
+ success: false,
832
+ statusCode: response.status,
833
+ error: `API error ${response.status}`,
834
+ details: data,
835
+ }
836
+ }
837
+
838
+ console.error(`[AI Usage] api.request: ${method.toUpperCase()} ${apiPath} → ${response.status} in ${callDuration}ms (${responseText.length} bytes)`)
839
+
840
+ // Add mutation warning for non-GET calls
841
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) {
842
+ return {
843
+ success: true,
844
+ statusCode: response.status,
845
+ data,
846
+ _note: 'WRITE operation performed. Only do writes when user explicitly requested data modification.',
847
+ }
848
+ }
849
+
850
+ return {
851
+ success: true,
852
+ statusCode: response.status,
853
+ data,
854
+ }
855
+ }
856
+ }
857
+
858
+ function tryParseJson(text: string): unknown {
859
+ try {
860
+ return JSON.parse(text)
861
+ } catch {
862
+ return text
863
+ }
864
+ }