@open-mercato/ai-assistant 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e

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 (27) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +9 -33
  3. package/dist/modules/ai_assistant/api/tools/route.js +2 -2
  4. package/dist/modules/ai_assistant/api/tools/route.js.map +2 -2
  5. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +55 -10
  6. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  7. package/dist/modules/ai_assistant/lib/in-process-client.js +2 -2
  8. package/dist/modules/ai_assistant/lib/in-process-client.js.map +2 -2
  9. package/dist/modules/ai_assistant/lib/mcp-server.js +2 -3
  10. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  11. package/dist/modules/ai_assistant/lib/tool-input-schema.js +13 -0
  12. package/dist/modules/ai_assistant/lib/tool-input-schema.js.map +7 -0
  13. package/dist/modules/ai_assistant/lib/tool-loader.js +16 -2
  14. package/dist/modules/ai_assistant/lib/tool-loader.js.map +2 -2
  15. package/dist/modules/ai_assistant/lib/tool-registry.js +1 -1
  16. package/dist/modules/ai_assistant/lib/tool-registry.js.map +2 -2
  17. package/package.json +6 -6
  18. package/src/modules/ai_assistant/api/tools/route.ts +2 -2
  19. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +128 -1
  20. package/src/modules/ai_assistant/lib/__tests__/tool-input-schema.test.ts +52 -0
  21. package/src/modules/ai_assistant/lib/__tests__/tool-loader.test.ts +65 -0
  22. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +107 -12
  23. package/src/modules/ai_assistant/lib/in-process-client.ts +2 -2
  24. package/src/modules/ai_assistant/lib/mcp-server.ts +2 -3
  25. package/src/modules/ai_assistant/lib/tool-input-schema.ts +33 -0
  26. package/src/modules/ai_assistant/lib/tool-loader.ts +18 -1
  27. package/src/modules/ai_assistant/lib/tool-registry.ts +1 -1
@@ -17,6 +17,7 @@
17
17
  import { createLogger } from '@open-mercato/shared/lib/logger'
18
18
  import path from 'node:path'
19
19
  import fs from 'node:fs'
20
+ import crypto from 'node:crypto'
20
21
  import { createRequire } from 'node:module'
21
22
  import { fileURLToPath, pathToFileURL } from 'node:url'
22
23
 
@@ -72,6 +73,10 @@ export function findGeneratedFile(fileName: string): string | null {
72
73
  * lazily through the workspace's normal module resolution. Eagerly bundling
73
74
  * pulls Next.js / route-handler internals into the `.mjs` and breaks at runtime
74
75
  * (e.g. `next/server` package-exports map).
76
+ *
77
+ * The `@app` local module entries are the one exception: those targets are raw
78
+ * app TypeScript with no compiled sibling, so they are compiled separately
79
+ * (see `compileAppLocalModuleEntries`) and the registry points at the artifact.
75
80
  */
76
81
  export async function compileAndImportGenerated(tsPath: string): Promise<Record<string, unknown>> {
77
82
  const useJestCjsArtifact = isJestRuntime()
@@ -83,17 +88,25 @@ export async function compileAndImportGenerated(tsPath: string): Promise<Record<
83
88
  throw new Error(`Generated file not found: ${tsPath}`)
84
89
  }
85
90
 
91
+ const runtime = useJestCjsArtifact ? 'cjs' : 'esm'
92
+ const tsSource = fs.readFileSync(tsPath, 'utf-8')
93
+ // Runs on every call, not only when the registry itself is stale: the
94
+ // artifact path is stable, so a registry cache hit would otherwise pin an
95
+ // app module's compiled output to whatever it was when the registry was
96
+ // last regenerated.
97
+ const appLocalArtifacts = await compileAppLocalModuleEntries(tsSource, appRoot, runtime)
98
+
86
99
  const jsExists = fs.existsSync(jsPath)
87
100
  const needsCompile =
88
101
  !jsExists || fs.statSync(tsPath).mtimeMs > fs.statSync(jsPath).mtimeMs
89
102
 
90
103
  if (needsCompile) {
91
104
  const esbuild = await import('esbuild')
92
- const tsSource = fs.readFileSync(tsPath, 'utf-8')
93
105
  const aliasRewritten = rewriteGeneratedAliasImportsForRuntime(
94
106
  tsSource,
95
107
  appRoot,
96
- useJestCjsArtifact ? 'cjs' : 'esm',
108
+ runtime,
109
+ appLocalArtifacts,
97
110
  )
98
111
  const result = await esbuild.transform(aliasRewritten, {
99
112
  loader: 'ts',
@@ -115,6 +128,73 @@ function isJestRuntime(): boolean {
115
128
  return typeof process.env.JEST_WORKER_ID === 'string'
116
129
  }
117
130
 
131
+ /** Every `../../src/...` specifier the generator emitted for `@app` local modules. */
132
+ export function collectAppLocalSpecifiers(source: string): string[] {
133
+ const specifiers = new Set<string>()
134
+ for (const [, specifier] of source.matchAll(APP_LOCAL_STATIC_IMPORT)) specifiers.add(specifier)
135
+ for (const [, specifier] of source.matchAll(APP_LOCAL_DYNAMIC_IMPORT)) specifiers.add(specifier)
136
+ return [...specifiers].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
137
+ }
138
+
139
+ /** Stable, collision-free artifact name for one app-local specifier. */
140
+ function appLocalArtifactName(specifier: string, runtime: 'esm' | 'cjs'): string {
141
+ const slug = specifier.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '')
142
+ const digest = crypto.createHash('sha256').update(specifier).digest('hex').slice(0, 8)
143
+ return `${slug}-${digest}.${runtime === 'cjs' ? 'cjs' : 'mjs'}`
144
+ }
145
+
146
+ /**
147
+ * Compile every `@app` local module entry the generated registry references,
148
+ * and map each specifier to its artifact.
149
+ *
150
+ * Package-backed modules (`@open-mercato/*`) never reach here — their bare
151
+ * specifiers resolve through `node_modules` to compiled `.js`. App-local
152
+ * modules have no compiled sibling, and Node cannot load their `.ts` source
153
+ * directly: relative specifiers need explicit extensions under type stripping,
154
+ * and the module's own graph (`./di`, `./data/entities`) carries decorator and
155
+ * enum syntax that type stripping rejects outright. Bundling the entry with
156
+ * every package import left external is what makes the source loadable.
157
+ *
158
+ * A module that fails to compile is logged and left pointing at its raw source,
159
+ * which reproduces the pre-existing resolution error rather than silently
160
+ * dropping the module's tools from the registry.
161
+ */
162
+ async function compileAppLocalModuleEntries(
163
+ source: string,
164
+ appRoot: string,
165
+ runtime: 'esm' | 'cjs',
166
+ ): Promise<Map<string, string>> {
167
+ const specifiers = collectAppLocalSpecifiers(source)
168
+ const artifacts = new Map<string, string>()
169
+ if (specifiers.length === 0) return artifacts
170
+
171
+ const generatedDir = path.join(appRoot, '.mercato', 'generated')
172
+ const { compileAppSourceFile } = await import('@open-mercato/shared/lib/bootstrap/dynamicLoader')
173
+
174
+ for (const specifier of specifiers) {
175
+ const target = path.resolve(generatedDir, specifier)
176
+ const tsPath = fs.existsSync(`${target}.ts`)
177
+ ? `${target}.ts`
178
+ : fs.existsSync(target) && target.endsWith('.ts')
179
+ ? target
180
+ : null
181
+ if (tsPath === null) continue
182
+
183
+ const outFile = path.join(generatedDir, 'app-modules', appLocalArtifactName(specifier, runtime))
184
+ try {
185
+ await compileAppSourceFile(tsPath, { appRoot, outFile, format: runtime })
186
+ artifacts.set(specifier, outFile)
187
+ } catch (error) {
188
+ logger.warn('Could not compile an app-local module entry for the generated registry', {
189
+ specifier,
190
+ err: error,
191
+ })
192
+ }
193
+ }
194
+
195
+ return artifacts
196
+ }
197
+
118
198
  const UNSAFE_JS_STRING_CHAR_ESCAPES: Record<number, string> = {
119
199
  0x3c: '\\u003C', // < — HTML/script-tag breakout
120
200
  0x3e: '\\u003E', // > — HTML/script-tag breakout
@@ -166,33 +246,48 @@ function toSafeJsStringLiteral(value: string): string {
166
246
  * relative to. Package-backed modules (`@open-mercato/*`) are unaffected —
167
247
  * their bare specifiers resolve through `node_modules` to compiled `.js`.
168
248
  *
169
- * Both shapes reuse the same `.ts`-suffix probe so a source-only TypeScript
170
- * target is loaded directly (Node strips types). Other specifiers (bare
249
+ * When `appLocalArtifacts` maps a shape-2 specifier to a compiled artifact, that
250
+ * artifact wins: pointing Node at raw app TypeScript only works while the file
251
+ * and its whole graph stay within what type stripping accepts, which app
252
+ * modules do not (see `compileAppLocalModuleEntries`). Without a mapping both
253
+ * shapes fall back to the same `.ts`-suffix probe. Other specifiers (bare
171
254
  * packages, sibling `./` imports) are left untouched. Exported for unit testing.
172
255
  */
173
- export function rewriteGeneratedAliasImports(source: string, appRoot: string): string {
174
- return rewriteGeneratedAliasImportsForRuntime(source, appRoot, 'esm')
256
+ export function rewriteGeneratedAliasImports(
257
+ source: string,
258
+ appRoot: string,
259
+ appLocalArtifacts?: Map<string, string>,
260
+ ): string {
261
+ return rewriteGeneratedAliasImportsForRuntime(source, appRoot, 'esm', appLocalArtifacts)
175
262
  }
176
263
 
264
+ const APP_LOCAL_STATIC_IMPORT = /from\s+["']((?:\.\.\/)+src\/[^"']+)["']/g
265
+ const APP_LOCAL_DYNAMIC_IMPORT = /import\s*\(\s*["']((?:\.\.\/)+src\/[^"']+)["']\s*\)/g
266
+
177
267
  function rewriteGeneratedAliasImportsForRuntime(
178
268
  source: string,
179
269
  appRoot: string,
180
270
  runtime: 'esm' | 'cjs',
271
+ appLocalArtifacts: Map<string, string> = new Map(),
181
272
  ): string {
182
273
  const generatedDir = path.join(appRoot, '.mercato', 'generated')
274
+ const toRuntimeLiteral = (target: string): string =>
275
+ toSafeJsStringLiteral(runtime === 'esm' ? pathToFileURL(target).href : target)
183
276
  const toResolvedLiteral = (target: string): string => {
184
277
  const candidate = fs.existsSync(target)
185
278
  ? target
186
279
  : fs.existsSync(target + '.ts')
187
280
  ? target + '.ts'
188
281
  : target
189
- const specifier = runtime === 'esm' ? pathToFileURL(candidate).href : candidate
190
- return toSafeJsStringLiteral(specifier)
282
+ return toRuntimeLiteral(candidate)
191
283
  }
192
284
  const resolveAlias = (relativePath: string): string =>
193
285
  toResolvedLiteral(path.join(appRoot, relativePath))
194
- const resolveRelative = (specifier: string): string =>
195
- toResolvedLiteral(path.resolve(generatedDir, specifier))
286
+ const resolveRelative = (specifier: string): string => {
287
+ const artifact = appLocalArtifacts.get(specifier)
288
+ if (artifact !== undefined) return toRuntimeLiteral(artifact)
289
+ return toResolvedLiteral(path.resolve(generatedDir, specifier))
290
+ }
196
291
  return source
197
292
  .replace(/from\s+["']@\/([^"']+)["']/g, (_match, relativePath: string) => {
198
293
  return `from ${resolveAlias(relativePath)}`
@@ -200,10 +295,10 @@ function rewriteGeneratedAliasImportsForRuntime(
200
295
  .replace(/import\s*\(\s*["']@\/([^"']+)["']\s*\)/g, (_match, relativePath: string) => {
201
296
  return `import(${resolveAlias(relativePath)})`
202
297
  })
203
- .replace(/from\s+["']((?:\.\.\/)+src\/[^"']+)["']/g, (_match, specifier: string) => {
298
+ .replace(APP_LOCAL_STATIC_IMPORT, (_match, specifier: string) => {
204
299
  return `from ${resolveRelative(specifier)}`
205
300
  })
206
- .replace(/import\s*\(\s*["']((?:\.\.\/)+src\/[^"']+)["']\s*\)/g, (_match, specifier: string) => {
301
+ .replace(APP_LOCAL_DYNAMIC_IMPORT, (_match, specifier: string) => {
207
302
  return `import(${resolveRelative(specifier)})`
208
303
  })
209
304
  }
@@ -1,6 +1,6 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
2
  import type { z } from 'zod'
3
- import { zodToJsonSchema } from 'zod-to-json-schema'
3
+ import { toolInputJsonSchema } from './tool-input-schema'
4
4
  import { getToolRegistry } from './tool-registry'
5
5
  import { executeTool } from './tool-executor'
6
6
  import { loadAllModuleTools } from './tool-loader'
@@ -137,7 +137,7 @@ export class InProcessMcpClient implements McpClientInterface {
137
137
  return accessibleTools.map((tool) => ({
138
138
  name: tool.name,
139
139
  description: tool.description,
140
- inputSchema: zodToJsonSchema(tool.inputSchema as any) as Record<string, unknown>,
140
+ inputSchema: toolInputJsonSchema(tool.inputSchema),
141
141
  }))
142
142
  }
143
143
 
@@ -4,7 +4,7 @@ import {
4
4
  ListToolsRequestSchema,
5
5
  CallToolRequestSchema,
6
6
  } from '@modelcontextprotocol/sdk/types.js'
7
- import { zodToJsonSchema } from 'zod-to-json-schema'
7
+ import { toolInputJsonSchema } from './tool-input-schema'
8
8
  import { getToolRegistry } from './tool-registry'
9
9
  import { executeTool } from './tool-executor'
10
10
  import { loadAllModuleTools, indexToolsForSearch } from './tool-loader'
@@ -132,8 +132,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
132
132
  tools: accessibleTools.map((tool) => ({
133
133
  name: tool.name,
134
134
  description: tool.description,
135
- // Cast to any for Zod v4 compatibility with zod-to-json-schema
136
- inputSchema: zodToJsonSchema(tool.inputSchema as any) as Record<string, unknown>,
135
+ inputSchema: toolInputJsonSchema(tool.inputSchema),
137
136
  })),
138
137
  }
139
138
  })
@@ -0,0 +1,33 @@
1
+ import { z, type ZodTypeAny } from 'zod'
2
+
3
+ /**
4
+ * Publish a tool's declared input as JSON Schema.
5
+ *
6
+ * Every place a tool is advertised — the MCP `tools/list` handler, the in-process client and
7
+ * `GET /api/ai_assistant/tools` — used to call `zod-to-json-schema`, a Zod **3** converter. This
8
+ * repository is on Zod 4, whose internals that package cannot read, so it silently returned
9
+ * `{ "$schema": "…" }` for every tool: no `properties`, no `required`, nothing. It did not throw
10
+ * and nothing failed a type check, so the tools kept listing and a model was simply never told
11
+ * that `example.get_customer_priority` takes a `customerId`. An argument-free tool looked
12
+ * identical to one with three required fields.
13
+ *
14
+ * Zod 4 ships its own converter, so the fix is to use it. Two deliberate choices:
15
+ *
16
+ * - `io: 'input'` — a tool schema describes what the CALLER sends. With the default (`'output'`)
17
+ * a field carrying a `.default()` is published as required-on-output, which is the opposite of
18
+ * what a caller needs to know.
19
+ * - `unrepresentable: 'any'` — the default throws on a type with no JSON Schema form (`z.date()`,
20
+ * `z.bigint()`). Throwing here would take down the whole tool listing because one tool declared
21
+ * a date, so such a field is published as an unconstrained value instead.
22
+ *
23
+ * A schema that still cannot be converted degrades to the empty object schema rather than
24
+ * propagating: listing the remaining tools is worth more than failing the request.
25
+ */
26
+ export function toolInputJsonSchema(schema: ZodTypeAny | undefined): Record<string, unknown> {
27
+ if (!schema) return { type: 'object', properties: {} }
28
+ try {
29
+ return z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as Record<string, unknown>
30
+ } catch {
31
+ return { type: 'object', properties: {} }
32
+ }
33
+ }
@@ -17,6 +17,8 @@ import {
17
17
 
18
18
  const logger = createLogger('ai_assistant').child({ component: 'tools' })
19
19
 
20
+ let allModuleToolsLoad: Promise<void> | null = null
21
+
20
22
  /**
21
23
  * Module tool definition as exported from ai-tools.ts files.
22
24
  */
@@ -235,7 +237,7 @@ export async function loadGeneratedModuleAiTools(): Promise<number> {
235
237
  * Dynamically load tools from known module paths.
236
238
  * This is called during MCP server startup.
237
239
  */
238
- export async function loadAllModuleTools(): Promise<void> {
240
+ async function loadAllModuleToolsUncached(): Promise<void> {
239
241
  // 1. Register built-in tools
240
242
  registerMcpTool(contextWhoamiTool, { moduleId: 'context' })
241
243
  logger.debug('Registered built-in context_whoami tool')
@@ -275,6 +277,21 @@ export async function loadAllModuleTools(): Promise<void> {
275
277
  }
276
278
  }
277
279
 
280
+ export function loadAllModuleTools(): Promise<void> {
281
+ if (!allModuleToolsLoad) {
282
+ allModuleToolsLoad = loadAllModuleToolsUncached().catch((error) => {
283
+ allModuleToolsLoad = null
284
+ throw error
285
+ })
286
+ }
287
+ return allModuleToolsLoad
288
+ }
289
+
290
+ /** @__internal Test-only hook — reset the process-wide loader memo. */
291
+ export function resetAllModuleToolsLoadForTests(): void {
292
+ allModuleToolsLoad = null
293
+ }
294
+
278
295
  /**
279
296
  * Index all registered tools for hybrid search discovery.
280
297
  * This should be called after loadAllModuleTools() when the search service is available.
@@ -19,7 +19,7 @@ class ToolRegistryImpl implements McpToolRegistry {
19
19
  throw new Error('MCP tool must define a name')
20
20
  }
21
21
 
22
- if (this.tools.has(tool.name)) {
22
+ if (this.tools.has(tool.name) && options?.moduleId !== 'ai_overrides') {
23
23
  logger.warn('Tool already registered, overwriting', { toolName: tool.name })
24
24
  }
25
25