@open-mercato/ai-assistant 0.4.9-develop-8c36c096d5 → 0.4.9-develop-be6e1cf49c

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
@@ -1,7 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { registerMcpTool, getToolRegistry } from "./tool-registry.js";
3
3
  import { ToolSearchService } from "./tool-search.js";
4
- import { loadApiDiscoveryTools } from "./api-discovery-tools.js";
5
4
  const contextWhoamiTool = {
6
5
  name: "context_whoami",
7
6
  description: "Get the current authentication context including tenant ID, organization ID, user ID, and available features. Use this to understand your current scope before performing operations.",
@@ -37,120 +36,11 @@ async function loadAllModuleTools() {
37
36
  registerMcpTool(contextWhoamiTool, { moduleId: "context" });
38
37
  console.error("[MCP Tools] Registered built-in context_whoami tool");
39
38
  try {
40
- const { entityGraphTools } = await import("./entity-graph-tools.js");
41
- for (const tool of entityGraphTools) {
42
- registerMcpTool(tool, { moduleId: "schema" });
43
- }
44
- console.error(`[MCP Tools] Registered ${entityGraphTools.length} entity graph tools`);
45
- } catch (error) {
46
- console.error("[MCP Tools] Could not load entity graph tools:", error);
47
- }
48
- try {
49
- const pathModule = await import("node:path");
50
- const path = pathModule.default;
51
- const { pathToFileURL } = await import("node:url");
52
- const fsModule = await import("node:fs");
53
- const fs = fsModule.default;
54
- const { findAppRoot, findAllApps } = await import("@open-mercato/shared/lib/bootstrap/appResolver");
55
- let appRoot = findAppRoot();
56
- if (!appRoot) {
57
- let current = process.cwd();
58
- while (current !== path.dirname(current)) {
59
- const appsDir = path.join(current, "apps");
60
- if (fs.existsSync(appsDir)) {
61
- const apps = findAllApps(current);
62
- if (apps.length > 0) {
63
- appRoot = apps[0];
64
- break;
65
- }
66
- }
67
- current = path.dirname(current);
68
- }
69
- }
70
- if (!appRoot) {
71
- console.error(`[MCP Tools] Could not find app root with .mercato/generated directory`);
72
- return;
73
- }
74
- const tsPath = path.join(appRoot.generatedDir, "ai-tools.generated.ts");
75
- if (!fs.existsSync(tsPath)) {
76
- console.error(`[MCP Tools] No auto-discovered tools (run npm run modules:prepare to generate)`);
77
- } else {
78
- const jsPath = tsPath.replace(/\.ts$/, ".mjs");
79
- const jsExists = fs.existsSync(jsPath);
80
- const needsCompile = !jsExists || fs.statSync(tsPath).mtimeMs > fs.statSync(jsPath).mtimeMs;
81
- if (needsCompile) {
82
- console.error(`[MCP Tools] Compiling ai-tools.generated.ts...`);
83
- const esbuild = await import("esbuild");
84
- const appRoot2 = path.dirname(path.dirname(path.dirname(tsPath)));
85
- const aliasPlugin = {
86
- name: "alias-resolver",
87
- setup(build) {
88
- build.onResolve({ filter: /^@\// }, (args) => {
89
- const resolved = path.join(appRoot2, args.path.slice(2));
90
- if (!fs.existsSync(resolved) && fs.existsSync(resolved + ".ts")) {
91
- return { path: resolved + ".ts" };
92
- }
93
- if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, "index.ts"))) {
94
- return { path: path.join(resolved, "index.ts") };
95
- }
96
- return { path: resolved };
97
- });
98
- }
99
- };
100
- const externalNonJsonPlugin = {
101
- name: "external-non-json",
102
- setup(build) {
103
- build.onResolve({ filter: /^[^./]/ }, (args) => {
104
- if (/^[a-zA-Z]:/.test(args.path)) {
105
- return null;
106
- }
107
- if (args.path.endsWith(".json")) {
108
- return null;
109
- }
110
- return { path: args.path, external: true };
111
- });
112
- }
113
- };
114
- await esbuild.build({
115
- entryPoints: [tsPath],
116
- outfile: jsPath,
117
- bundle: true,
118
- format: "esm",
119
- platform: "node",
120
- target: "node18",
121
- plugins: [aliasPlugin, externalNonJsonPlugin],
122
- loader: { ".json": "json" }
123
- });
124
- console.error(`[MCP Tools] Compiled to ${jsPath}`);
125
- }
126
- const fileUrl = pathToFileURL(jsPath).href;
127
- const { aiToolConfigEntries } = await import(fileUrl);
128
- let totalTools = 0;
129
- for (const { moduleId, tools } of aiToolConfigEntries) {
130
- if (Array.isArray(tools) && tools.length > 0) {
131
- loadModuleTools(moduleId, tools);
132
- totalTools += tools.length;
133
- console.error(`[MCP Tools] Loaded ${tools.length} tools from ${moduleId}`);
134
- }
135
- }
136
- if (totalTools === 0) {
137
- console.error(`[MCP Tools] No module tools found in generated registry`);
138
- }
139
- }
140
- } catch (error) {
141
- const errorMessage = error instanceof Error ? error.message : String(error);
142
- console.error(`[MCP Tools] Error loading auto-discovered tools:`, errorMessage);
143
- if (errorMessage.includes("Cannot find module") || errorMessage.includes("ENOENT")) {
144
- console.error(`[MCP Tools] No auto-discovered tools (run npm run modules:prepare to generate)`);
145
- } else {
146
- console.error(`[MCP Tools] Could not load auto-discovered tools:`, error);
147
- }
148
- }
149
- try {
150
- const apiToolCount = await loadApiDiscoveryTools();
151
- console.error(`[MCP Tools] Loaded ${apiToolCount} API discovery tools`);
39
+ const { loadCodeModeTools } = await import("./codemode-tools.js");
40
+ const toolCount = await loadCodeModeTools();
41
+ console.error(`[MCP Tools] Registered ${toolCount} Code Mode tools`);
152
42
  } catch (error) {
153
- console.error("[MCP Tools] Could not load API discovery tools:", error);
43
+ console.error("[MCP Tools] Could not load Code Mode tools:", error);
154
44
  }
155
45
  }
156
46
  async function indexToolsForSearch(searchService, force = false) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/tool-loader.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type { SearchService } from '@open-mercato/search/service'\nimport { registerMcpTool, getToolRegistry } from './tool-registry'\nimport type { McpToolDefinition, McpToolContext } from './types'\nimport { ToolSearchService } from './tool-search'\nimport { loadApiDiscoveryTools } from './api-discovery-tools'\n\n/**\n * Module tool definition as exported from ai-tools.ts files.\n */\ntype ModuleAiTool = {\n name: string\n description: string\n inputSchema: any\n requiredFeatures?: string[]\n handler: (input: any, ctx: any) => Promise<unknown>\n}\n\n/**\n * Built-in context.whoami tool that returns the current authentication context.\n * This is useful for AI to understand its current tenant/org scope.\n */\nconst contextWhoamiTool: McpToolDefinition = {\n name: 'context_whoami',\n description:\n 'Get the current authentication context including tenant ID, organization ID, user ID, and available features. Use this to understand your current scope before performing operations.',\n inputSchema: z.object({}),\n requiredFeatures: [], // No specific feature required - available to all authenticated users\n handler: async (_input: unknown, ctx: McpToolContext) => {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n isSuperAdmin: ctx.isSuperAdmin,\n features: ctx.userFeatures,\n featureCount: ctx.userFeatures.length,\n }\n },\n}\n\n/**\n * Load and register AI tools from a module's ai-tools.ts export.\n *\n * @param moduleId - The module identifier (e.g., 'search', 'customers')\n * @param tools - Array of tool definitions from the module\n */\nexport function loadModuleTools(moduleId: string, tools: ModuleAiTool[]): void {\n for (const tool of tools) {\n registerMcpTool(\n {\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema,\n requiredFeatures: tool.requiredFeatures,\n handler: tool.handler,\n } as McpToolDefinition,\n { moduleId }\n )\n }\n}\n\n/**\n * Dynamically load tools from known module paths.\n * This is called during MCP server startup.\n */\nexport async function loadAllModuleTools(): Promise<void> {\n // 1. Register built-in tools\n registerMcpTool(contextWhoamiTool, { moduleId: 'context' })\n console.error('[MCP Tools] Registered built-in context_whoami tool')\n\n // 2. Register entity graph tools\n try {\n const { entityGraphTools } = await import('./entity-graph-tools')\n for (const tool of entityGraphTools) {\n registerMcpTool(tool, { moduleId: 'schema' })\n }\n console.error(`[MCP Tools] Registered ${entityGraphTools.length} entity graph tools`)\n } catch (error) {\n console.error('[MCP Tools] Could not load entity graph tools:', error)\n }\n\n // 3. Load auto-discovered ai-tools.ts files from modules\n // Tools are discovered by the generator and registered in ai-tools.generated.ts\n try {\n const pathModule = await import('node:path')\n const path = pathModule.default\n const { pathToFileURL } = await import('node:url')\n const fsModule = await import('node:fs')\n const fs = fsModule.default\n const { findAppRoot, findAllApps } = await import('@open-mercato/shared/lib/bootstrap/appResolver')\n\n // Find the app root (contains .mercato/generated/)\n let appRoot = findAppRoot()\n\n // Fallback: Try monorepo structure if not found\n if (!appRoot) {\n let current = process.cwd()\n while (current !== path.dirname(current)) {\n const appsDir = path.join(current, 'apps')\n if (fs.existsSync(appsDir)) {\n const apps = findAllApps(current)\n if (apps.length > 0) {\n appRoot = apps[0]\n break\n }\n }\n current = path.dirname(current)\n }\n }\n\n if (!appRoot) {\n console.error(`[MCP Tools] Could not find app root with .mercato/generated directory`)\n return\n }\n\n const tsPath = path.join(appRoot.generatedDir, 'ai-tools.generated.ts')\n\n // Check if file exists\n if (!fs.existsSync(tsPath)) {\n console.error(`[MCP Tools] No auto-discovered tools (run npm run modules:prepare to generate)`)\n } else {\n // Compile TypeScript to JavaScript using esbuild (same approach as dynamicLoader)\n const jsPath = tsPath.replace(/\\.ts$/, '.mjs')\n const jsExists = fs.existsSync(jsPath)\n\n const needsCompile = !jsExists ||\n fs.statSync(tsPath).mtimeMs > fs.statSync(jsPath).mtimeMs\n\n if (needsCompile) {\n console.error(`[MCP Tools] Compiling ai-tools.generated.ts...`)\n const esbuild = await import('esbuild')\n const appRoot = path.dirname(path.dirname(path.dirname(tsPath)))\n\n // Plugin to resolve @/ alias to app root\n const aliasPlugin: import('esbuild').Plugin = {\n name: 'alias-resolver',\n setup(build) {\n build.onResolve({ filter: /^@\\// }, (args) => {\n const resolved = path.join(appRoot, args.path.slice(2))\n if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.ts')) {\n return { path: resolved + '.ts' }\n }\n if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, 'index.ts'))) {\n return { path: path.join(resolved, 'index.ts') }\n }\n return { path: resolved }\n })\n },\n }\n\n // Plugin to mark non-JSON package imports as external\n const externalNonJsonPlugin: import('esbuild').Plugin = {\n name: 'external-non-json',\n setup(build) {\n // Filter matches paths that don't start with . or / (package imports)\n build.onResolve({ filter: /^[^./]/ }, (args) => {\n // Skip Windows absolute paths (e.g., C:\\...) - they're local files, not packages\n if (/^[a-zA-Z]:/.test(args.path)) {\n return null\n }\n if (args.path.endsWith('.json')) {\n return null\n }\n return { path: args.path, external: true }\n })\n },\n }\n\n await esbuild.build({\n entryPoints: [tsPath],\n outfile: jsPath,\n bundle: true,\n format: 'esm',\n platform: 'node',\n target: 'node18',\n plugins: [aliasPlugin, externalNonJsonPlugin],\n loader: { '.json': 'json' },\n })\n console.error(`[MCP Tools] Compiled to ${jsPath}`)\n }\n\n // Import the compiled JavaScript\n const fileUrl = pathToFileURL(jsPath).href\n const { aiToolConfigEntries } = (await import(fileUrl)) as {\n aiToolConfigEntries: Array<{ moduleId: string; tools: unknown[] }>\n }\n\n let totalTools = 0\n for (const { moduleId, tools } of aiToolConfigEntries) {\n if (Array.isArray(tools) && tools.length > 0) {\n loadModuleTools(moduleId, tools as ModuleAiTool[])\n totalTools += tools.length\n console.error(`[MCP Tools] Loaded ${tools.length} tools from ${moduleId}`)\n }\n }\n if (totalTools === 0) {\n console.error(`[MCP Tools] No module tools found in generated registry`)\n }\n }\n } catch (error) {\n // Generated file might not exist yet (first run) or be empty\n // This is expected when no modules have ai-tools.ts files\n const errorMessage = error instanceof Error ? error.message : String(error)\n console.error(`[MCP Tools] Error loading auto-discovered tools:`, errorMessage)\n if (errorMessage.includes('Cannot find module') || errorMessage.includes('ENOENT')) {\n console.error(`[MCP Tools] No auto-discovered tools (run npm run modules:prepare to generate)`)\n } else {\n console.error(`[MCP Tools] Could not load auto-discovered tools:`, error)\n }\n }\n\n // 4. Load API discovery tools (api_discover, api_execute)\n try {\n const apiToolCount = await loadApiDiscoveryTools()\n console.error(`[MCP Tools] Loaded ${apiToolCount} API discovery tools`)\n } catch (error) {\n console.error('[MCP Tools] Could not load API discovery tools:', error)\n }\n}\n\n/**\n * Index all registered tools for hybrid search discovery.\n * This should be called after loadAllModuleTools() when the search service is available.\n *\n * @param searchService - The search service from DI container\n * @param force - Force re-indexing even if checksums match\n * @returns Indexing result with statistics\n */\nexport async function indexToolsForSearch(\n searchService: SearchService,\n force = false\n): Promise<{\n indexed: number\n skipped: number\n strategies: string[]\n checksum: string\n}> {\n const registry = getToolRegistry()\n const toolSearchService = new ToolSearchService(searchService, registry)\n\n try {\n const result = await toolSearchService.indexTools(force)\n\n console.error(`[MCP Tools] Indexed ${result.indexed} tools for search`)\n console.error(`[MCP Tools] Search strategies available: ${result.strategies.join(', ')}`)\n\n if (result.skipped > 0) {\n console.error(`[MCP Tools] Skipped ${result.skipped} tools (unchanged)`)\n }\n\n return result\n } catch (error) {\n console.error('[MCP Tools] Failed to index tools for search:', error)\n throw error\n }\n}\n\n/**\n * Create a ToolSearchService instance for tool discovery.\n * Use this to get a configured service for discovering relevant tools.\n *\n * @param searchService - The search service from DI container\n * @returns Configured ToolSearchService\n */\nexport function createToolSearchService(searchService: SearchService): ToolSearchService {\n const registry = getToolRegistry()\n return new ToolSearchService(searchService, registry)\n}\n"],
5
- "mappings": "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",
6
- "names": ["appRoot"]
4
+ "sourcesContent": ["import { z } from 'zod'\nimport type { SearchService } from '@open-mercato/search/service'\nimport { registerMcpTool, getToolRegistry } from './tool-registry'\nimport type { McpToolDefinition, McpToolContext } from './types'\nimport { ToolSearchService } from './tool-search'\n\n/**\n * Module tool definition as exported from ai-tools.ts files.\n */\ntype ModuleAiTool = {\n name: string\n description: string\n inputSchema: any\n requiredFeatures?: string[]\n handler: (input: any, ctx: any) => Promise<unknown>\n}\n\n/**\n * Built-in context.whoami tool that returns the current authentication context.\n * This is useful for AI to understand its current tenant/org scope.\n */\nconst contextWhoamiTool: McpToolDefinition = {\n name: 'context_whoami',\n description:\n 'Get the current authentication context including tenant ID, organization ID, user ID, and available features. Use this to understand your current scope before performing operations.',\n inputSchema: z.object({}),\n requiredFeatures: [], // No specific feature required - available to all authenticated users\n handler: async (_input: unknown, ctx: McpToolContext) => {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n isSuperAdmin: ctx.isSuperAdmin,\n features: ctx.userFeatures,\n featureCount: ctx.userFeatures.length,\n }\n },\n}\n\n/**\n * Load and register AI tools from a module's ai-tools.ts export.\n *\n * @param moduleId - The module identifier (e.g., 'search', 'customers')\n * @param tools - Array of tool definitions from the module\n */\nexport function loadModuleTools(moduleId: string, tools: ModuleAiTool[]): void {\n for (const tool of tools) {\n registerMcpTool(\n {\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema,\n requiredFeatures: tool.requiredFeatures,\n handler: tool.handler,\n } as McpToolDefinition,\n { moduleId }\n )\n }\n}\n\n/**\n * Dynamically load tools from known module paths.\n * This is called during MCP server startup.\n */\nexport async function loadAllModuleTools(): Promise<void> {\n // 1. Register built-in tools\n registerMcpTool(contextWhoamiTool, { moduleId: 'context' })\n console.error('[MCP Tools] Registered built-in context_whoami tool')\n\n // 2. Register Code Mode tools (search + execute)\n // These two tools replace the previous api_discover, call_api, discover_schema,\n // and all module-specific AI tools. The AI writes JavaScript that runs in a\n // node:vm sandbox with access to the OpenAPI spec and api.request().\n try {\n const { loadCodeModeTools } = await import('./codemode-tools')\n const toolCount = await loadCodeModeTools()\n console.error(`[MCP Tools] Registered ${toolCount} Code Mode tools`)\n } catch (error) {\n console.error('[MCP Tools] Could not load Code Mode tools:', error)\n }\n\n // Note: Auto-discovered module AI tools (from ai-tools.generated.ts) and\n // legacy API discovery tools (find_api, call_api, discover_schema) are no\n // longer loaded. Code Mode's search + execute tools cover all use cases.\n}\n\n/**\n * Index all registered tools for hybrid search discovery.\n * This should be called after loadAllModuleTools() when the search service is available.\n *\n * @param searchService - The search service from DI container\n * @param force - Force re-indexing even if checksums match\n * @returns Indexing result with statistics\n */\nexport async function indexToolsForSearch(\n searchService: SearchService,\n force = false\n): Promise<{\n indexed: number\n skipped: number\n strategies: string[]\n checksum: string\n}> {\n const registry = getToolRegistry()\n const toolSearchService = new ToolSearchService(searchService, registry)\n\n try {\n const result = await toolSearchService.indexTools(force)\n\n console.error(`[MCP Tools] Indexed ${result.indexed} tools for search`)\n console.error(`[MCP Tools] Search strategies available: ${result.strategies.join(', ')}`)\n\n if (result.skipped > 0) {\n console.error(`[MCP Tools] Skipped ${result.skipped} tools (unchanged)`)\n }\n\n return result\n } catch (error) {\n console.error('[MCP Tools] Failed to index tools for search:', error)\n throw error\n }\n}\n\n/**\n * Create a ToolSearchService instance for tool discovery.\n * Use this to get a configured service for discovering relevant tools.\n *\n * @param searchService - The search service from DI container\n * @returns Configured ToolSearchService\n */\nexport function createToolSearchService(searchService: SearchService): ToolSearchService {\n const registry = getToolRegistry()\n return new ToolSearchService(searchService, registry)\n}\n"],
5
+ "mappings": "AAAA,SAAS,SAAS;AAElB,SAAS,iBAAiB,uBAAuB;AAEjD,SAAS,yBAAyB;AAiBlC,MAAM,oBAAuC;AAAA,EAC3C,MAAM;AAAA,EACN,aACE;AAAA,EACF,aAAa,EAAE,OAAO,CAAC,CAAC;AAAA,EACxB,kBAAkB,CAAC;AAAA;AAAA,EACnB,SAAS,OAAO,QAAiB,QAAwB;AACvD,WAAO;AAAA,MACL,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,MACpB,QAAQ,IAAI;AAAA,MACZ,cAAc,IAAI;AAAA,MAClB,UAAU,IAAI;AAAA,MACd,cAAc,IAAI,aAAa;AAAA,IACjC;AAAA,EACF;AACF;AAQO,SAAS,gBAAgB,UAAkB,OAA6B;AAC7E,aAAW,QAAQ,OAAO;AACxB;AAAA,MACE;AAAA,QACE,MAAM,KAAK;AAAA,QACX,aAAa,KAAK;AAAA,QAClB,aAAa,KAAK;AAAA,QAClB,kBAAkB,KAAK;AAAA,QACvB,SAAS,KAAK;AAAA,MAChB;AAAA,MACA,EAAE,SAAS;AAAA,IACb;AAAA,EACF;AACF;AAMA,eAAsB,qBAAoC;AAExD,kBAAgB,mBAAmB,EAAE,UAAU,UAAU,CAAC;AAC1D,UAAQ,MAAM,qDAAqD;AAMnE,MAAI;AACF,UAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,kBAAkB;AAC7D,UAAM,YAAY,MAAM,kBAAkB;AAC1C,YAAQ,MAAM,0BAA0B,SAAS,kBAAkB;AAAA,EACrE,SAAS,OAAO;AACd,YAAQ,MAAM,+CAA+C,KAAK;AAAA,EACpE;AAKF;AAUA,eAAsB,oBACpB,eACA,QAAQ,OAMP;AACD,QAAM,WAAW,gBAAgB;AACjC,QAAM,oBAAoB,IAAI,kBAAkB,eAAe,QAAQ;AAEvE,MAAI;AACF,UAAM,SAAS,MAAM,kBAAkB,WAAW,KAAK;AAEvD,YAAQ,MAAM,uBAAuB,OAAO,OAAO,mBAAmB;AACtE,YAAQ,MAAM,4CAA4C,OAAO,WAAW,KAAK,IAAI,CAAC,EAAE;AAExF,QAAI,OAAO,UAAU,GAAG;AACtB,cAAQ,MAAM,uBAAuB,OAAO,OAAO,oBAAoB;AAAA,IACzE;AAEA,WAAO;AAAA,EACT,SAAS,OAAO;AACd,YAAQ,MAAM,iDAAiD,KAAK;AACpE,UAAM;AAAA,EACR;AACF;AASO,SAAS,wBAAwB,eAAiD;AACvF,QAAM,WAAW,gBAAgB;AACjC,SAAO,IAAI,kBAAkB,eAAe,QAAQ;AACtD;",
6
+ "names": []
7
7
  }
@@ -0,0 +1,26 @@
1
+ const DEFAULT_MAX_CHARS = 4e4;
2
+ function truncateResult(value, maxChars = DEFAULT_MAX_CHARS) {
3
+ if (value === void 0) return "undefined";
4
+ let serialized;
5
+ try {
6
+ serialized = JSON.stringify(value, circularReplacer(), 2);
7
+ } catch {
8
+ serialized = String(value);
9
+ }
10
+ if (serialized.length <= maxChars) return serialized;
11
+ return serialized.slice(0, maxChars) + "\n\n... (truncated \u2014 refine your code to return less data)";
12
+ }
13
+ function circularReplacer() {
14
+ const seen = /* @__PURE__ */ new WeakSet();
15
+ return (_key, value) => {
16
+ if (typeof value === "object" && value !== null) {
17
+ if (seen.has(value)) return "[Circular]";
18
+ seen.add(value);
19
+ }
20
+ return value;
21
+ };
22
+ }
23
+ export {
24
+ truncateResult
25
+ };
26
+ //# sourceMappingURL=truncate.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/lib/truncate.ts"],
4
+ "sourcesContent": ["/**\n * Response Size Limiter\n *\n * Serializes values with circular reference handling and truncates\n * to a maximum character count suitable for LLM consumption.\n */\n\nconst DEFAULT_MAX_CHARS = 40_000 // ~10K tokens\n\n/**\n * Serialize and truncate a value for LLM consumption.\n *\n * Handles circular references, non-serializable values, and oversized output.\n */\nexport function truncateResult(value: unknown, maxChars = DEFAULT_MAX_CHARS): string {\n if (value === undefined) return 'undefined'\n\n let serialized: string\n try {\n serialized = JSON.stringify(value, circularReplacer(), 2)\n } catch {\n serialized = String(value)\n }\n\n if (serialized.length <= maxChars) return serialized\n\n return (\n serialized.slice(0, maxChars) +\n '\\n\\n... (truncated \u2014 refine your code to return less data)'\n )\n}\n\n/**\n * JSON.stringify replacer that replaces circular references with \"[Circular]\".\n */\nfunction circularReplacer() {\n const seen = new WeakSet()\n return (_key: string, value: unknown) => {\n if (typeof value === 'object' && value !== null) {\n if (seen.has(value)) return '[Circular]'\n seen.add(value)\n }\n return value\n }\n}\n"],
5
+ "mappings": "AAOA,MAAM,oBAAoB;AAOnB,SAAS,eAAe,OAAgB,WAAW,mBAA2B;AACnF,MAAI,UAAU,OAAW,QAAO;AAEhC,MAAI;AACJ,MAAI;AACF,iBAAa,KAAK,UAAU,OAAO,iBAAiB,GAAG,CAAC;AAAA,EAC1D,QAAQ;AACN,iBAAa,OAAO,KAAK;AAAA,EAC3B;AAEA,MAAI,WAAW,UAAU,SAAU,QAAO;AAE1C,SACE,WAAW,MAAM,GAAG,QAAQ,IAC5B;AAEJ;AAKA,SAAS,mBAAmB;AAC1B,QAAM,OAAO,oBAAI,QAAQ;AACzB,SAAO,CAAC,MAAc,UAAmB;AACvC,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,UAAI,KAAK,IAAI,KAAK,EAAG,QAAO;AAC5B,WAAK,IAAI,KAAK;AAAA,IAChB;AACA,WAAO;AAAA,EACT;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,23 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ watchman: false,
6
+ rootDir: '.',
7
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
8
+ moduleNameMapper: {
9
+ '^@open-mercato/ai-assistant/(.*)$': '<rootDir>/src/$1',
10
+ },
11
+ transform: {
12
+ '^.+\\.tsx?$': [
13
+ 'ts-jest',
14
+ {
15
+ tsconfig: {
16
+ jsx: 'react-jsx',
17
+ },
18
+ },
19
+ ],
20
+ },
21
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
22
+ passWithNoTests: true,
23
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.4.9-develop-8c36c096d5",
3
+ "version": "0.4.9-develop-be6e1cf49c",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
7
7
  "build": "node build.mjs",
8
8
  "watch": "node watch.mjs",
9
9
  "mcp:dev": "mercato ai_assistant mcp:dev",
10
- "mcp:serve": "mercato ai_assistant mcp:serve-http"
10
+ "mcp:serve": "mercato ai_assistant mcp:serve-http",
11
+ "test": "jest --config jest.config.cjs --forceExit"
11
12
  },
12
13
  "exports": {
13
14
  ".": "./dist/index.js",
@@ -93,12 +94,12 @@
93
94
  "zod-to-json-schema": "^3.25.1"
94
95
  },
95
96
  "peerDependencies": {
96
- "@open-mercato/shared": "0.4.9-develop-8c36c096d5",
97
- "@open-mercato/ui": "0.4.9-develop-8c36c096d5",
97
+ "@open-mercato/shared": "0.4.9-develop-be6e1cf49c",
98
+ "@open-mercato/ui": "0.4.9-develop-be6e1cf49c",
98
99
  "zod": ">=3.23.0"
99
100
  },
100
101
  "devDependencies": {
101
- "@open-mercato/cli": "0.4.9-develop-8c36c096d5",
102
+ "@open-mercato/cli": "0.4.9-develop-be6e1cf49c",
102
103
  "tsx": "^4.21.0"
103
104
  },
104
105
  "publishConfig": {
@@ -21,22 +21,102 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
21
21
  const CHAT_SYSTEM_INSTRUCTIONS = `
22
22
  You are a helpful business assistant for Open Mercato.
23
23
 
24
- EFFICIENCY - CRITICAL:
25
- - MINIMIZE tool calls. If you already have the information, DO NOT call tools again.
26
- - When you retrieve entity info or search results, REMEMBER and REUSE that data.
27
- - For simple queries, use ONE search and present results - don't over-fetch.
28
- - For updates: search once to find the record, then call_api once to update.
24
+ ═══════════════════════════════════════
25
+ ABSOLUTE RULES FOLLOW THESE OR BE CUT OFF
26
+ ═══════════════════════════════════════
27
+ 1. READ = GET only. If the user says find/list/show/search/get use only GET. NEVER call PUT/POST/DELETE for a read query.
28
+ 2. PUT path = collection path. id goes in the BODY, not the URL. Example: PUT /api/customers/companies with { id: '...', name: 'New' }. There are NO /{id} path segments.
29
+ 3. Confirm before ANY write. Before POST/PUT/DELETE: present your plan in business language, then STOP and wait for user to say "yes". Do NOT execute the write in the same turn.
30
+ 4. Maximum 4 tool calls per message. Hard limit is 10.
29
31
 
30
- STATUS UPDATES: Before each tool call, output a brief status line:
31
- - "🔍 Searching..." before search_query
32
- - "📋 Getting details..." before search_get or understand_entity
33
- - "🔗 Calling API..." before call_api
32
+ You have 2 tools both accept a "code" parameter with an async JavaScript arrow function.
33
+
34
+ TOOL: search discover endpoints and schemas (READ-ONLY, fast)
35
+ - spec.findEndpoints(keyword) [{ path, methods }]
36
+ - spec.describeEndpoint(path, method) → COMPACT: { requiredFields, optionalFields, nestedCollections, example, relatedEndpoints, relatedEntity }
37
+ - spec.describeEntity(keyword) → { className, fields, relationships }
38
+
39
+ TOOL: execute — make API calls (reads and writes)
40
+ - api.request({ method, path, query?, body? }) → { success, statusCode, data }
41
+
42
+ COMMON API PATHS (use directly — do NOT call findEndpoints for these):
43
+ /api/customers/companies — companies (GET list, POST create, PUT update)
44
+ /api/customers/people — contacts/people
45
+ /api/customers/deals — deals/opportunities
46
+ /api/customers/activities — activities/tasks
47
+ /api/sales/orders — sales orders
48
+ /api/sales/quotes — quotes
49
+ /api/sales/invoices — invoices
50
+ /api/catalog/products — products
51
+ /api/catalog/categories — categories
52
+
53
+ ═══════════════════════════════════════
54
+ RECIPES — follow EXACTLY for each task type
55
+ ═══════════════════════════════════════
56
+
57
+ FIND/LIST records (1 call):
58
+ For COMMON PATHS: skip describeEndpoint, go straight to execute.
59
+ 1. execute: api.request({ method: 'GET', path: '/api/<module>/<resource>' })
60
+ The "search" query param only matches indexed text fields — it will NOT match concepts like "Polish" or "large".
61
+ For conceptual/subjective queries, fetch ALL records and use YOUR reasoning to identify matches from the returned data.
62
+ For unknown paths: 1 search + 1 execute.
63
+
64
+ UPDATE a record (3-4 calls):
65
+ 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'PUT') → learn requestBody fields AND relatedEntity
66
+ 2. execute: GET the record → find it, get its ID
67
+ 3. execute: PUT to the COLLECTION path with id IN THE BODY:
68
+ api.request({ method: 'PUT', path: '/api/<module>/<resource>', body: { id: '<uuid>', ...changes } })
69
+ NOTE: All CRUD endpoints use the COLLECTION path. The id goes in the request BODY, not the URL. There are NO /{id} path segments.
70
+
71
+ CREATE a record (2-3 calls):
72
+ 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') → gives requiredFields, optionalFields, nestedCollections, and a working example
73
+ 2. Ask user for confirmation with the field values
74
+ 3. execute: api.request({ method: 'POST', ...body })
75
+ If the endpoint has nestedCollections (like lines), include them INLINE in the body — do NOT create them separately.
76
+ Use the "example" from describeEndpoint as your template — fill in real values.
77
+ Example — create a quote with line items:
78
+ api.request({ method: 'POST', path: '/api/sales/quotes', body: {
79
+ currencyCode: 'EUR', customerEntityId: '<company-uuid>',
80
+ lines: [{ currencyCode: 'EUR', quantity: 1, productId: '<product-uuid>', name: 'Product Name', kind: 'product' }]
81
+ }})
82
+ Do NOT create lines separately. Do NOT include id, quoteId, or total fields — the server generates them.
83
+
84
+ CREATE MULTIPLE records (2-3 calls):
85
+ 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') → learn fields + example
86
+ 2. execute: loop in one call:
87
+ async () => {
88
+ const results = [];
89
+ for (const item of items) {
90
+ results.push(await api.request({ method: 'POST', path: '...', body: item }));
91
+ }
92
+ return results;
93
+ }
94
+
95
+ DISCOVER (what endpoints/entities exist) (1 call):
96
+ 1. search: spec.findEndpoints('<keyword>') or spec.describeEntity('<keyword>')
97
+
98
+ ═══════════════════════════════════════
99
+ HARD RULES
100
+ ═══════════════════════════════════════
101
+ - MAXIMUM 4 tool calls per user message. You WILL be cut off after 10.
102
+ - NEVER call findEndpoints or describeEndpoint for COMMON PATHS listed above — use them directly with execute.
103
+ - NEVER call describeEntity if describeEndpoint already returned relatedEntity.
104
+ - NEVER repeat a search from earlier in the conversation — reuse previous results.
105
+ - NEVER make N+1 API calls (1 call per record). Fetch a list and reason about the results yourself.
106
+ - When you already have the data you need from a previous call, use it — do NOT fetch more data to "enrich" it.
107
+ - Do NOT write JavaScript filters/regex to match records. Fetch data with a simple api.request() call and use YOUR knowledge to interpret the results.
108
+ - The "search" query param is fulltext only — it won't match nationalities, categories, or subjective criteria. For those, fetch all and reason.
109
+ - describeEndpoint returns a COMPACT summary with requiredFields, optionalFields, and an example. Use the example as your template — fill in real values and send it.
110
+ - For fields you don't know, OMIT them — the API uses defaults for optional fields.
111
+ - NEVER try to set computed/total fields (amounts, totals, counts) — the server calculates them.
112
+ - For updates: describeEndpoint gives you the field names. Go straight to GET + PUT. PUT path is the COLLECTION path, id in BODY.
113
+ - For creates with children (e.g. quote + lines): include children INLINE in the body using the nestedCollections field name.
34
114
 
35
115
  RESPONSE RULES:
36
- - Be proactive - fetch data and present results, don't ask what the user wants to see
37
- - Never show technical terms, IDs, JSON, or internal reasoning
38
- - Present results in clean business language with **bold names** and bullet points
39
- - Only ask for confirmation before create/update/delete operations
116
+ - Be proactive fetch data and present results, don't ask what the user wants to see.
117
+ - Never show technical terms, IDs, JSON, or internal reasoning.
118
+ - Present results in clean business language with **bold names** and bullet points.
119
+ - Only ask for confirmation before create/update/delete operations.
40
120
  `.trim()
41
121
 
42
122
  export const metadata = {
@@ -68,7 +148,7 @@ async function getUserRoleIds(
68
148
 
69
149
  /**
70
150
  * Chat endpoint that routes messages to OpenCode agent.
71
- * OpenCode connects to MCP server for tool access (api_discover, api_execute, api_schema).
151
+ * OpenCode connects to MCP server for tool access (search, execute, context_whoami).
72
152
  *
73
153
  * Emits verbose SSE events for debugging:
74
154
  * - thinking: Agent started processing
@@ -154,6 +234,11 @@ export async function POST(req: NextRequest) {
154
234
  return NextResponse.json({ error: 'No user message found' }, { status: 400 })
155
235
  }
156
236
 
237
+ const chatStartTime = Date.now()
238
+ const messagePreview = lastUserMessage.slice(0, 80).replace(/\n/g, ' ')
239
+ console.error(`[AI Usage] Chat request: user=${auth.sub} session=${sessionId ? sessionId.slice(0, 16) + '...' : 'new'} message="${messagePreview}${lastUserMessage.length > 80 ? '...' : ''}"`)
240
+
241
+
157
242
  // For new sessions, create an ephemeral API key that inherits user permissions
158
243
  // The API key secret is encrypted and stored; MCP server recovers it via session token
159
244
  let sessionToken: string | null = null
@@ -201,6 +286,10 @@ export async function POST(req: NextRequest) {
201
286
 
202
287
  // Process in background - starts AFTER Response is returned so there's a reader for the stream
203
288
  ;(async () => {
289
+ let toolCallCount = 0
290
+ let lastTokens: { input?: number; output?: number } | undefined
291
+ let resultSessionId: string | undefined
292
+
204
293
  try {
205
294
  // Emit session-authorized event first (if we have a token)
206
295
  if (sessionToken) {
@@ -221,6 +310,11 @@ export async function POST(req: NextRequest) {
221
310
  sessionId,
222
311
  },
223
312
  async (event) => {
313
+ // Track usage from stream events
314
+ if (event.type === 'tool-call') toolCallCount++
315
+ if (event.type === 'metadata' && 'tokens' in event) lastTokens = event.tokens
316
+ if (event.type === 'done' && 'sessionId' in event) resultSessionId = event.sessionId
317
+
224
318
  await writeSSE(event)
225
319
  }
226
320
  )
@@ -231,6 +325,8 @@ export async function POST(req: NextRequest) {
231
325
  error: error instanceof Error ? error.message : 'OpenCode request failed',
232
326
  })
233
327
  } finally {
328
+ const durationMs = Date.now() - chatStartTime
329
+ console.error(`[AI Usage] Chat complete: user=${auth.sub} session=${(resultSessionId || sessionId || 'unknown').slice(0, 16)}... duration=${durationMs}ms toolCalls=${toolCallCount}${lastTokens ? ` tokens={in:${lastTokens.input || 0},out:${lastTokens.output || 0}}` : ''}`)
234
330
  await closeWriter()
235
331
  }
236
332
  })()
@@ -0,0 +1,129 @@
1
+ import { extractApiKeyFromHeaders, hasRequiredFeatures } from '../auth'
2
+
3
+ describe('extractApiKeyFromHeaders', () => {
4
+ describe('plain object headers', () => {
5
+ it('extracts from x-api-key header', () => {
6
+ expect(extractApiKeyFromHeaders({ 'x-api-key': 'omk_test123' })).toBe('omk_test123')
7
+ })
8
+
9
+ it('extracts from Authorization ApiKey header', () => {
10
+ expect(
11
+ extractApiKeyFromHeaders({ authorization: 'ApiKey omk_test123' })
12
+ ).toBe('omk_test123')
13
+ })
14
+
15
+ it('is case-insensitive for ApiKey prefix', () => {
16
+ expect(
17
+ extractApiKeyFromHeaders({ authorization: 'apikey omk_test123' })
18
+ ).toBe('omk_test123')
19
+ })
20
+
21
+ it('prefers x-api-key over Authorization', () => {
22
+ expect(
23
+ extractApiKeyFromHeaders({
24
+ 'x-api-key': 'omk_primary',
25
+ authorization: 'ApiKey omk_secondary',
26
+ })
27
+ ).toBe('omk_primary')
28
+ })
29
+
30
+ it('returns null when no key present', () => {
31
+ expect(extractApiKeyFromHeaders({})).toBeNull()
32
+ })
33
+
34
+ it('returns null for Bearer token (not API key)', () => {
35
+ expect(
36
+ extractApiKeyFromHeaders({ authorization: 'Bearer jwt_token' })
37
+ ).toBeNull()
38
+ })
39
+
40
+ it('trims whitespace', () => {
41
+ expect(extractApiKeyFromHeaders({ 'x-api-key': ' omk_test ' })).toBe('omk_test')
42
+ })
43
+ })
44
+
45
+ describe('Map headers', () => {
46
+ it('extracts from Map', () => {
47
+ const headers = new Map([['x-api-key', 'omk_map_test']])
48
+ expect(extractApiKeyFromHeaders(headers)).toBe('omk_map_test')
49
+ })
50
+ })
51
+ })
52
+
53
+ describe('hasRequiredFeatures', () => {
54
+ it('returns true for super admin', () => {
55
+ expect(hasRequiredFeatures(['admin.only'], [], true)).toBe(true)
56
+ })
57
+
58
+ it('returns true when no features required', () => {
59
+ expect(hasRequiredFeatures([], [], false)).toBe(true)
60
+ expect(hasRequiredFeatures(undefined, [], false)).toBe(true)
61
+ })
62
+
63
+ it('returns true for direct feature match', () => {
64
+ expect(
65
+ hasRequiredFeatures(
66
+ ['customers.view'],
67
+ ['customers.view', 'customers.edit'],
68
+ false
69
+ )
70
+ ).toBe(true)
71
+ })
72
+
73
+ it('returns false for missing feature', () => {
74
+ expect(
75
+ hasRequiredFeatures(['customers.delete'], ['customers.view'], false)
76
+ ).toBe(false)
77
+ })
78
+
79
+ it('returns true for global wildcard', () => {
80
+ expect(hasRequiredFeatures(['customers.view'], ['*'], false)).toBe(true)
81
+ })
82
+
83
+ it('returns true for prefix wildcard match', () => {
84
+ expect(
85
+ hasRequiredFeatures(['customers.people.view'], ['customers.*'], false)
86
+ ).toBe(true)
87
+ })
88
+
89
+ it('returns false for non-matching prefix wildcard', () => {
90
+ expect(
91
+ hasRequiredFeatures(['sales.view'], ['customers.*'], false)
92
+ ).toBe(false)
93
+ })
94
+
95
+ it('requires all features (AND logic)', () => {
96
+ expect(
97
+ hasRequiredFeatures(
98
+ ['customers.view', 'sales.view'],
99
+ ['customers.view'],
100
+ false
101
+ )
102
+ ).toBe(false)
103
+
104
+ expect(
105
+ hasRequiredFeatures(
106
+ ['customers.view', 'sales.view'],
107
+ ['customers.view', 'sales.view'],
108
+ false
109
+ )
110
+ ).toBe(true)
111
+ })
112
+
113
+ it('delegates to rbacService when provided', () => {
114
+ const rbacService = {
115
+ hasAllFeatures: jest.fn().mockReturnValue(true),
116
+ }
117
+ const result = hasRequiredFeatures(
118
+ ['customers.view'],
119
+ ['customers.view'],
120
+ false,
121
+ rbacService as any
122
+ )
123
+ expect(result).toBe(true)
124
+ expect(rbacService.hasAllFeatures).toHaveBeenCalledWith(
125
+ ['customers.view'],
126
+ ['customers.view']
127
+ )
128
+ })
129
+ })