@open-mercato/ai-assistant 0.4.9-develop-8c36c096d5 → 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.
- package/AGENTS.md +78 -37
- package/dist/modules/ai_assistant/api/chat/route.js +104 -13
- package/dist/modules/ai_assistant/api/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/lib/api-endpoint-index.js +97 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +610 -0
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +7 -0
- package/dist/modules/ai_assistant/lib/http-server.js +65 -7
- package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js +16 -1
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-server.js +17 -0
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/opencode-handlers.js +32 -0
- package/dist/modules/ai_assistant/lib/opencode-handlers.js.map +2 -2
- package/dist/modules/ai_assistant/lib/sandbox.js +124 -0
- package/dist/modules/ai_assistant/lib/sandbox.js.map +7 -0
- package/dist/modules/ai_assistant/lib/session-memory.js +103 -0
- package/dist/modules/ai_assistant/lib/session-memory.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-loader.js +4 -114
- package/dist/modules/ai_assistant/lib/tool-loader.js.map +3 -3
- package/dist/modules/ai_assistant/lib/truncate.js +26 -0
- package/dist/modules/ai_assistant/lib/truncate.js.map +7 -0
- package/jest.config.cjs +23 -0
- package/package.json +6 -5
- package/src/modules/ai_assistant/api/chat/route.ts +110 -14
- package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +129 -0
- package/src/modules/ai_assistant/lib/__tests__/sandbox.test.ts +642 -0
- package/src/modules/ai_assistant/lib/__tests__/session-memory.test.ts +82 -0
- package/src/modules/ai_assistant/lib/__tests__/truncate.test.ts +76 -0
- package/src/modules/ai_assistant/lib/api-endpoint-index.ts +143 -0
- package/src/modules/ai_assistant/lib/codemode-tools.ts +864 -0
- package/src/modules/ai_assistant/lib/http-server.ts +86 -9
- package/src/modules/ai_assistant/lib/mcp-dev-server.ts +19 -0
- package/src/modules/ai_assistant/lib/mcp-server.ts +21 -0
- package/src/modules/ai_assistant/lib/opencode-handlers.ts +40 -0
- package/src/modules/ai_assistant/lib/sandbox.ts +192 -0
- package/src/modules/ai_assistant/lib/session-memory.ts +174 -0
- package/src/modules/ai_assistant/lib/tool-loader.ts +11 -145
- package/src/modules/ai_assistant/lib/truncate.ts +45 -0
- package/src/modules/ai_assistant/lib/types.ts +2 -0
package/AGENTS.md
CHANGED
|
@@ -8,15 +8,13 @@
|
|
|
8
8
|
- Expose module tools to an AI agent via MCP (Model Context Protocol)
|
|
9
9
|
- Enable dynamic API discovery so the agent can call any endpoint without hardcoded tools
|
|
10
10
|
- Build the Raycast-style Command Palette UI (Cmd+K) for user interaction
|
|
11
|
-
- Combine search-based and OpenAPI-based tool discovery
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
Four core components to understand:
|
|
14
13
|
|
|
15
14
|
1. **OpenCode Agent** — AI backend that processes natural language and executes tools
|
|
16
15
|
2. **MCP HTTP Server** — Exposes tools to OpenCode via HTTP on port 3001
|
|
17
|
-
3. **
|
|
16
|
+
3. **Code Mode Tools** — 2 meta-tools (`search` + `execute`) where the AI writes JavaScript that runs in a `node:vm` sandbox
|
|
18
17
|
4. **Command Palette UI** — Raycast-style frontend interface
|
|
19
|
-
5. **Hybrid Tool Discovery** — Merges search-based and OpenAPI introspection results
|
|
20
18
|
|
|
21
19
|
## Common Tasks
|
|
22
20
|
|
|
@@ -57,11 +55,11 @@ registerMcpTool({
|
|
|
57
55
|
|
|
58
56
|
### Add New API Endpoints to Discovery
|
|
59
57
|
|
|
60
|
-
APIs are automatically
|
|
58
|
+
APIs are automatically available via the Code Mode `search` tool (reads the OpenAPI spec at runtime). To add new endpoints:
|
|
61
59
|
|
|
62
60
|
1. Define the endpoint in your module's route file with an `openApi` export
|
|
63
|
-
2. Regenerate the OpenAPI spec
|
|
64
|
-
3. Restart the MCP server
|
|
61
|
+
2. Regenerate the OpenAPI spec (`yarn modules:prepare`)
|
|
62
|
+
3. Restart the MCP server — the `search` tool's `spec.paths` will include the new endpoint
|
|
65
63
|
|
|
66
64
|
### Debug Tool Calls
|
|
67
65
|
|
|
@@ -125,9 +123,9 @@ When modifying this stack, follow these constraints:
|
|
|
125
123
|
│ ▼ │
|
|
126
124
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
127
125
|
│ │ MCP HTTP Server (:3001) │ │
|
|
128
|
-
│ │ • Exposes
|
|
129
|
-
│ │ •
|
|
130
|
-
│ │ •
|
|
126
|
+
│ │ • Exposes 3 tools: context_whoami + Code Mode (search, execute) │ │
|
|
127
|
+
│ │ • search: AI writes JS to query OpenAPI spec + entity schemas │ │
|
|
128
|
+
│ │ • execute: AI writes JS to make API calls via api.request() │ │
|
|
131
129
|
│ │ • Authentication via x-api-key header │ │
|
|
132
130
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
|
133
131
|
│ │
|
|
@@ -159,8 +157,12 @@ packages/ai-assistant/
|
|
|
159
157
|
│ │ ├── lib/
|
|
160
158
|
│ │ │ ├── opencode-client.ts # OpenCode server client
|
|
161
159
|
│ │ │ ├── opencode-handlers.ts # Request handlers for OpenCode
|
|
162
|
-
│ │ │ ├──
|
|
163
|
-
│ │ │ ├──
|
|
160
|
+
│ │ │ ├── codemode-tools.ts # Code Mode search + execute tools
|
|
161
|
+
│ │ │ ├── sandbox.ts # node:vm sandbox executor
|
|
162
|
+
│ │ │ ├── truncate.ts # Response size limiter
|
|
163
|
+
│ │ │ ├── api-endpoint-index.ts # OpenAPI endpoint indexing + raw spec cache
|
|
164
|
+
│ │ │ ├── api-discovery-tools.ts # (legacy, unused) old find_api/call_api
|
|
165
|
+
│ │ │ ├── entity-graph-tools.ts # (legacy, unused) old discover_schema
|
|
164
166
|
│ │ │ ├── http-server.ts # MCP HTTP server implementation
|
|
165
167
|
│ │ │ ├── mcp-server.ts # MCP stdio server implementation
|
|
166
168
|
│ │ │ ├── tool-registry.ts # Global tool registration
|
|
@@ -225,30 +227,40 @@ When you need to interact with OpenCode, follow these rules:
|
|
|
225
227
|
}
|
|
226
228
|
```
|
|
227
229
|
|
|
228
|
-
## Rules for
|
|
230
|
+
## Rules for Code Mode Tools
|
|
229
231
|
|
|
230
|
-
Use
|
|
232
|
+
Use 2 meta-tools instead of individual endpoint/schema tools. The AI writes JavaScript that runs in a `node:vm` sandbox:
|
|
231
233
|
|
|
232
|
-
| Tool | When to use |
|
|
233
|
-
|
|
234
|
-
| `
|
|
235
|
-
| `
|
|
236
|
-
| `api_execute` | When you need to execute an API call with parameters |
|
|
234
|
+
| Tool | Sandbox globals | When to use |
|
|
235
|
+
|------|----------------|-------------|
|
|
236
|
+
| `search` | `spec` (OpenAPI paths + entity schemas) | When discovering endpoints, understanding schemas, or exploring the API surface |
|
|
237
|
+
| `execute` | `api.request()`, `context` | When making API calls to read or write data |
|
|
237
238
|
|
|
238
239
|
**Example workflow the agent follows**:
|
|
239
240
|
1. Agent receives: "Find all customers in New York"
|
|
240
|
-
2. Agent calls `
|
|
241
|
-
3. Agent calls `
|
|
242
|
-
4. Agent calls `
|
|
241
|
+
2. Agent calls `search({ code: 'async () => Object.keys(spec.paths).filter(p => p.includes("customer"))' })`
|
|
242
|
+
3. Agent calls `search({ code: 'async () => spec.paths["/api/customers/companies"]?.get' })` to see endpoint details
|
|
243
|
+
4. Agent calls `execute({ code: 'async () => api.request({ method: "GET", path: "/api/customers/companies", query: { city: "New York" } })' })`
|
|
243
244
|
|
|
244
|
-
|
|
245
|
+
**Sandbox safety**: Code runs in `node:vm` with only whitelisted globals. `fetch`, `require`, `process`, `fs`, `Buffer`, and network APIs are blocked. Execution times out after 30 seconds. API calls are capped at 50 per execution.
|
|
245
246
|
|
|
246
|
-
|
|
247
|
+
**When modifying Code Mode tools**: Edit `lib/codemode-tools.ts` for tool definitions, `lib/sandbox.ts` for the sandbox engine, `lib/truncate.ts` for response size limiting.
|
|
247
248
|
|
|
248
|
-
|
|
249
|
-
2. **OpenAPI-based**: Direct introspection of API endpoints
|
|
249
|
+
## MANDATORY: Use AskUserQuestion for Confirmations
|
|
250
250
|
|
|
251
|
-
|
|
251
|
+
> **This is the MOST IMPORTANT rule. NEVER skip this.**
|
|
252
|
+
|
|
253
|
+
Before ANY operation that modifies data (CREATE, UPDATE, DELETE):
|
|
254
|
+
|
|
255
|
+
1. **YOU MUST USE** the `AskUserQuestion` tool
|
|
256
|
+
2. Do NOT just write "Proceed?" in text
|
|
257
|
+
3. The `AskUserQuestion` tool will show buttons and WAIT for user response
|
|
258
|
+
4. Only proceed after user selects confirmation option
|
|
259
|
+
|
|
260
|
+
**Why This Matters:**
|
|
261
|
+
- Text like "Shall I proceed?" does NOT pause execution
|
|
262
|
+
- Only the `AskUserQuestion` tool actually waits for user input
|
|
263
|
+
- Without it, the AI may proceed without real confirmation
|
|
252
264
|
|
|
253
265
|
## Rules for the Chat Flow
|
|
254
266
|
|
|
@@ -503,21 +515,24 @@ async function handleOpenCodeMessage(options: {
|
|
|
503
515
|
function extractTextFromResponse(result: OpenCodeMessage): string
|
|
504
516
|
```
|
|
505
517
|
|
|
506
|
-
## Rules for
|
|
518
|
+
## Rules for Code Mode Internals
|
|
507
519
|
|
|
508
|
-
Located in `lib/
|
|
520
|
+
Located in `lib/codemode-tools.ts`, `lib/sandbox.ts`, `lib/truncate.ts`.
|
|
509
521
|
|
|
510
522
|
```typescript
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
523
|
+
// lib/codemode-tools.ts — Tool definitions
|
|
524
|
+
loadCodeModeTools(): Promise<number> // Registers search + execute, returns 2
|
|
525
|
+
|
|
526
|
+
// lib/sandbox.ts — Sandbox engine
|
|
527
|
+
createSandbox(globals, options?): { execute: (code: string) => Promise<SandboxResult> }
|
|
528
|
+
normalizeCode(code: string): string // Strip markdown fences, validate shape
|
|
529
|
+
|
|
530
|
+
// lib/truncate.ts — Response limiting
|
|
531
|
+
truncateResult(value, maxChars?): string // Default 40K chars (~10K tokens)
|
|
519
532
|
```
|
|
520
533
|
|
|
534
|
+
**Legacy files kept but unused**: `lib/api-discovery-tools.ts` (old find_api/call_api) and `lib/entity-graph-tools.ts` (old discover_schema) remain in the tree but are no longer imported.
|
|
535
|
+
|
|
521
536
|
## Rules for the API Endpoint Index
|
|
522
537
|
|
|
523
538
|
Located in `lib/api-endpoint-index.ts`. Use the singleton pattern — never instantiate directly:
|
|
@@ -1034,6 +1049,32 @@ if (tool.requiredFeatures?.length) {
|
|
|
1034
1049
|
|
|
1035
1050
|
## Changelog
|
|
1036
1051
|
|
|
1052
|
+
### 2026-02-22 - Code Mode Tools (search + execute)
|
|
1053
|
+
|
|
1054
|
+
**Major change**: Replaced all individual API/schema/module tools with 2 Code Mode meta-tools following Cloudflare's Code Mode pattern.
|
|
1055
|
+
|
|
1056
|
+
**What changed**:
|
|
1057
|
+
- Added `search` tool: AI writes JavaScript to query the OpenAPI spec + entity schemas via a `spec` global
|
|
1058
|
+
- Added `execute` tool: AI writes JavaScript to make API calls via `api.request()` in a `node:vm` sandbox
|
|
1059
|
+
- Removed `find_api`, `call_api`, `discover_schema` tools (files kept but no longer imported)
|
|
1060
|
+
- Removed auto-discovered module AI tools from `ai-tools.generated.ts`
|
|
1061
|
+
- Token savings: from ~10+ tool schemas to exactly 2, with fixed footprint regardless of API surface growth
|
|
1062
|
+
|
|
1063
|
+
**Files created**:
|
|
1064
|
+
- `lib/codemode-tools.ts` — `search` and `execute` tool definitions
|
|
1065
|
+
- `lib/sandbox.ts` — `node:vm` sandbox executor with security restrictions
|
|
1066
|
+
- `lib/truncate.ts` — Response size limiter (40K chars / ~10K tokens)
|
|
1067
|
+
|
|
1068
|
+
**Files modified**:
|
|
1069
|
+
- `lib/api-endpoint-index.ts` — Added `getRawOpenApiSpec()` for raw spec caching
|
|
1070
|
+
- `lib/tool-loader.ts` — Loads Code Mode tools instead of legacy tools + module tools
|
|
1071
|
+
- `lib/http-server.ts` — Pre-caches raw OpenAPI spec at startup
|
|
1072
|
+
- `lib/mcp-server.ts` — Generates entity graph and caches spec for stdio mode
|
|
1073
|
+
|
|
1074
|
+
**Files kept but unused**:
|
|
1075
|
+
- `lib/api-discovery-tools.ts` — Old find_api/call_api (no longer imported)
|
|
1076
|
+
- `lib/entity-graph-tools.ts` — Old discover_schema (no longer imported)
|
|
1077
|
+
|
|
1037
1078
|
### 2026-01-17 - Session Persistence Fix
|
|
1038
1079
|
|
|
1039
1080
|
**Lesson learned:** Never use `Promise.race` for SSE completion — the HTTP response resolves before SSE can emit the `done` event. Always await only the SSE event promise.
|
|
@@ -14,22 +14,102 @@ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
|
14
14
|
const CHAT_SYSTEM_INSTRUCTIONS = `
|
|
15
15
|
You are a helpful business assistant for Open Mercato.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
18
|
+
ABSOLUTE RULES \u2014 FOLLOW THESE OR BE CUT OFF
|
|
19
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
20
|
+
1. READ = GET only. If the user says find/list/show/search/get \u2192 use only GET. NEVER call PUT/POST/DELETE for a read query.
|
|
21
|
+
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.
|
|
22
|
+
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.
|
|
23
|
+
4. Maximum 4 tool calls per message. Hard limit is 10.
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
25
|
+
You have 2 tools \u2014 both accept a "code" parameter with an async JavaScript arrow function.
|
|
26
|
+
|
|
27
|
+
TOOL: search \u2014 discover endpoints and schemas (READ-ONLY, fast)
|
|
28
|
+
- spec.findEndpoints(keyword) \u2192 [{ path, methods }]
|
|
29
|
+
- spec.describeEndpoint(path, method) \u2192 COMPACT: { requiredFields, optionalFields, nestedCollections, example, relatedEndpoints, relatedEntity }
|
|
30
|
+
- spec.describeEntity(keyword) \u2192 { className, fields, relationships }
|
|
31
|
+
|
|
32
|
+
TOOL: execute \u2014 make API calls (reads and writes)
|
|
33
|
+
- api.request({ method, path, query?, body? }) \u2192 { success, statusCode, data }
|
|
34
|
+
|
|
35
|
+
COMMON API PATHS (use directly \u2014 do NOT call findEndpoints for these):
|
|
36
|
+
/api/customers/companies \u2014 companies (GET list, POST create, PUT update)
|
|
37
|
+
/api/customers/people \u2014 contacts/people
|
|
38
|
+
/api/customers/deals \u2014 deals/opportunities
|
|
39
|
+
/api/customers/activities \u2014 activities/tasks
|
|
40
|
+
/api/sales/orders \u2014 sales orders
|
|
41
|
+
/api/sales/quotes \u2014 quotes
|
|
42
|
+
/api/sales/invoices \u2014 invoices
|
|
43
|
+
/api/catalog/products \u2014 products
|
|
44
|
+
/api/catalog/categories \u2014 categories
|
|
45
|
+
|
|
46
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
47
|
+
RECIPES \u2014 follow EXACTLY for each task type
|
|
48
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
49
|
+
|
|
50
|
+
FIND/LIST records (1 call):
|
|
51
|
+
For COMMON PATHS: skip describeEndpoint, go straight to execute.
|
|
52
|
+
1. execute: api.request({ method: 'GET', path: '/api/<module>/<resource>' })
|
|
53
|
+
The "search" query param only matches indexed text fields \u2014 it will NOT match concepts like "Polish" or "large".
|
|
54
|
+
For conceptual/subjective queries, fetch ALL records and use YOUR reasoning to identify matches from the returned data.
|
|
55
|
+
For unknown paths: 1 search + 1 execute.
|
|
56
|
+
|
|
57
|
+
UPDATE a record (3-4 calls):
|
|
58
|
+
1. search: spec.describeEndpoint('/api/<module>/<resource>', 'PUT') \u2192 learn requestBody fields AND relatedEntity
|
|
59
|
+
2. execute: GET the record \u2192 find it, get its ID
|
|
60
|
+
3. execute: PUT to the COLLECTION path with id IN THE BODY:
|
|
61
|
+
api.request({ method: 'PUT', path: '/api/<module>/<resource>', body: { id: '<uuid>', ...changes } })
|
|
62
|
+
NOTE: All CRUD endpoints use the COLLECTION path. The id goes in the request BODY, not the URL. There are NO /{id} path segments.
|
|
63
|
+
|
|
64
|
+
CREATE a record (2-3 calls):
|
|
65
|
+
1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 gives requiredFields, optionalFields, nestedCollections, and a working example
|
|
66
|
+
2. Ask user for confirmation with the field values
|
|
67
|
+
3. execute: api.request({ method: 'POST', ...body })
|
|
68
|
+
If the endpoint has nestedCollections (like lines), include them INLINE in the body \u2014 do NOT create them separately.
|
|
69
|
+
Use the "example" from describeEndpoint as your template \u2014 fill in real values.
|
|
70
|
+
Example \u2014 create a quote with line items:
|
|
71
|
+
api.request({ method: 'POST', path: '/api/sales/quotes', body: {
|
|
72
|
+
currencyCode: 'EUR', customerEntityId: '<company-uuid>',
|
|
73
|
+
lines: [{ currencyCode: 'EUR', quantity: 1, productId: '<product-uuid>', name: 'Product Name', kind: 'product' }]
|
|
74
|
+
}})
|
|
75
|
+
Do NOT create lines separately. Do NOT include id, quoteId, or total fields \u2014 the server generates them.
|
|
76
|
+
|
|
77
|
+
CREATE MULTIPLE records (2-3 calls):
|
|
78
|
+
1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 learn fields + example
|
|
79
|
+
2. execute: loop in one call:
|
|
80
|
+
async () => {
|
|
81
|
+
const results = [];
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
results.push(await api.request({ method: 'POST', path: '...', body: item }));
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
DISCOVER (what endpoints/entities exist) (1 call):
|
|
89
|
+
1. search: spec.findEndpoints('<keyword>') or spec.describeEntity('<keyword>')
|
|
90
|
+
|
|
91
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
92
|
+
HARD RULES
|
|
93
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
94
|
+
- MAXIMUM 4 tool calls per user message. You WILL be cut off after 10.
|
|
95
|
+
- NEVER call findEndpoints or describeEndpoint for COMMON PATHS listed above \u2014 use them directly with execute.
|
|
96
|
+
- NEVER call describeEntity if describeEndpoint already returned relatedEntity.
|
|
97
|
+
- NEVER repeat a search from earlier in the conversation \u2014 reuse previous results.
|
|
98
|
+
- NEVER make N+1 API calls (1 call per record). Fetch a list and reason about the results yourself.
|
|
99
|
+
- When you already have the data you need from a previous call, use it \u2014 do NOT fetch more data to "enrich" it.
|
|
100
|
+
- 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.
|
|
101
|
+
- The "search" query param is fulltext only \u2014 it won't match nationalities, categories, or subjective criteria. For those, fetch all and reason.
|
|
102
|
+
- describeEndpoint returns a COMPACT summary with requiredFields, optionalFields, and an example. Use the example as your template \u2014 fill in real values and send it.
|
|
103
|
+
- For fields you don't know, OMIT them \u2014 the API uses defaults for optional fields.
|
|
104
|
+
- NEVER try to set computed/total fields (amounts, totals, counts) \u2014 the server calculates them.
|
|
105
|
+
- For updates: describeEndpoint gives you the field names. Go straight to GET + PUT. PUT path is the COLLECTION path, id in BODY.
|
|
106
|
+
- For creates with children (e.g. quote + lines): include children INLINE in the body using the nestedCollections field name.
|
|
27
107
|
|
|
28
108
|
RESPONSE RULES:
|
|
29
|
-
- Be proactive
|
|
30
|
-
- Never show technical terms, IDs, JSON, or internal reasoning
|
|
31
|
-
- Present results in clean business language with **bold names** and bullet points
|
|
32
|
-
- Only ask for confirmation before create/update/delete operations
|
|
109
|
+
- Be proactive \u2014 fetch data and present results, don't ask what the user wants to see.
|
|
110
|
+
- Never show technical terms, IDs, JSON, or internal reasoning.
|
|
111
|
+
- Present results in clean business language with **bold names** and bullet points.
|
|
112
|
+
- Only ask for confirmation before create/update/delete operations.
|
|
33
113
|
`.trim();
|
|
34
114
|
const metadata = {
|
|
35
115
|
POST: { requireAuth: true, requireFeatures: ["ai_assistant.view"] }
|
|
@@ -97,6 +177,9 @@ async function POST(req) {
|
|
|
97
177
|
if (!lastUserMessage) {
|
|
98
178
|
return NextResponse.json({ error: "No user message found" }, { status: 400 });
|
|
99
179
|
}
|
|
180
|
+
const chatStartTime = Date.now();
|
|
181
|
+
const messagePreview = lastUserMessage.slice(0, 80).replace(/\n/g, " ");
|
|
182
|
+
console.error(`[AI Usage] Chat request: user=${auth.sub} session=${sessionId ? sessionId.slice(0, 16) + "..." : "new"} message="${messagePreview}${lastUserMessage.length > 80 ? "..." : ""}"`);
|
|
100
183
|
let sessionToken = null;
|
|
101
184
|
if (!sessionId) {
|
|
102
185
|
try {
|
|
@@ -130,6 +213,9 @@ async function POST(req) {
|
|
|
130
213
|
}
|
|
131
214
|
messageToSend += lastUserMessage;
|
|
132
215
|
(async () => {
|
|
216
|
+
let toolCallCount = 0;
|
|
217
|
+
let lastTokens;
|
|
218
|
+
let resultSessionId;
|
|
133
219
|
try {
|
|
134
220
|
if (sessionToken) {
|
|
135
221
|
console.log("[AI Chat] Emitting session-authorized event");
|
|
@@ -145,6 +231,9 @@ async function POST(req) {
|
|
|
145
231
|
sessionId
|
|
146
232
|
},
|
|
147
233
|
async (event) => {
|
|
234
|
+
if (event.type === "tool-call") toolCallCount++;
|
|
235
|
+
if (event.type === "metadata" && "tokens" in event) lastTokens = event.tokens;
|
|
236
|
+
if (event.type === "done" && "sessionId" in event) resultSessionId = event.sessionId;
|
|
148
237
|
await writeSSE(event);
|
|
149
238
|
}
|
|
150
239
|
);
|
|
@@ -155,6 +244,8 @@ async function POST(req) {
|
|
|
155
244
|
error: error instanceof Error ? error.message : "OpenCode request failed"
|
|
156
245
|
});
|
|
157
246
|
} finally {
|
|
247
|
+
const durationMs = Date.now() - chatStartTime;
|
|
248
|
+
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}}` : ""}`);
|
|
158
249
|
await closeWriter();
|
|
159
250
|
}
|
|
160
251
|
})();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/ai_assistant/api/chat/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse, type NextRequest } from 'next/server'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport {\n handleOpenCodeMessageStreaming,\n type OpenCodeStreamEvent,\n} from '../../lib/opencode-handlers'\nimport { createOpenCodeClient } from '../../lib/opencode-client'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n generateSessionToken,\n createSessionApiKey,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\n/**\n * System instructions injected at the start of new chat sessions.\n * These ensure the AI follows the correct workflow for data operations.\n */\nconst CHAT_SYSTEM_INSTRUCTIONS = `\nYou are a helpful business assistant for Open Mercato.\n\nEFFICIENCY - CRITICAL:\n- MINIMIZE tool calls. If you already have the information, DO NOT call tools again.\n- When you retrieve entity info or search results, REMEMBER and REUSE that data.\n- For simple queries, use ONE search and present results - don't over-fetch.\n- For updates: search once to find the record, then call_api once to update.\n\nSTATUS UPDATES: Before each tool call, output a brief status line:\n- \"\uD83D\uDD0D Searching...\" before search_query\n- \"\uD83D\uDCCB Getting details...\" before search_get or understand_entity\n- \"\uD83D\uDD17 Calling API...\" before call_api\n\nRESPONSE RULES:\n- Be proactive - fetch data and present results, don't ask what the user wants to see\n- Never show technical terms, IDs, JSON, or internal reasoning\n- Present results in clean business language with **bold names** and bullet points\n- Only ask for confirmation before create/update/delete operations\n`.trim()\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\n/**\n * Get user's role IDs from the database.\n */\nasync function getUserRoleIds(\n em: EntityManager,\n userId: string,\n tenantId: string | null\n): Promise<string[]> {\n if (!tenantId) return []\n\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n return linkList\n .map((l) => (l.role as any)?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n}\n\n/**\n * Chat endpoint that routes messages to OpenCode agent.\n * OpenCode connects to MCP server for tool access (api_discover, api_execute, api_schema).\n *\n * Emits verbose SSE events for debugging:\n * - thinking: Agent started processing\n * - metadata: Model, tokens, timing info\n * - tool-call: Tool invocation with args\n * - tool-result: Tool response\n * - text: Response text\n * - question: Confirmation question from agent\n * - done: Complete with session ID\n * - error: Error occurred\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const body = await req.json()\n const { messages, sessionId, answerQuestion } = body as {\n messages?: Array<{ role: string; content: string }>\n sessionId?: string\n // For answering a question\n answerQuestion?: {\n questionId: string\n answer: number\n sessionId: string\n }\n }\n\n // Create SSE stream for frontend compatibility\n const encoder = new TextEncoder()\n const stream = new TransformStream()\n const writer = stream.writable.getWriter()\n let writerClosed = false\n\n const writeSSE = async (event: OpenCodeStreamEvent | { type: string; [key: string]: unknown }) => {\n if (writerClosed) return // Guard against writes after close\n try {\n const jsonStr = JSON.stringify(event)\n await writer.write(encoder.encode(`data: ${jsonStr}\\n\\n`))\n } catch (err) {\n // Writer may have been closed by client disconnect\n console.warn('[AI Chat] Failed to write SSE event:', event.type)\n }\n }\n\n const closeWriter = async () => {\n if (writerClosed) return\n writerClosed = true\n try {\n await writer.close()\n } catch {\n // Already closed\n }\n }\n\n // Handle question answer - simple JSON response, not SSE\n // The original SSE stream continues and will receive the follow-up response\n if (answerQuestion) {\n try {\n const client = createOpenCodeClient()\n await client.answerQuestion(answerQuestion.questionId, answerQuestion.answer)\n return NextResponse.json({ success: true })\n } catch (error) {\n console.error('[AI Chat] Answer error:', error)\n return NextResponse.json(\n { error: error instanceof Error ? error.message : 'Failed to answer question' },\n { status: 500 }\n )\n }\n }\n\n // Handle regular message\n if (!messages || !Array.isArray(messages)) {\n return NextResponse.json({ error: 'messages array is required' }, { status: 400 })\n }\n\n // Get the latest user message\n const lastUserMessage = messages.filter((m) => m.role === 'user').pop()?.content\n if (!lastUserMessage) {\n return NextResponse.json({ error: 'No user message found' }, { status: 400 })\n }\n\n // For new sessions, create an ephemeral API key that inherits user permissions\n // The API key secret is encrypted and stored; MCP server recovers it via session token\n let sessionToken: string | null = null\n if (!sessionId) {\n try {\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n // Get user's role IDs from database\n const userRoleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n // Generate session token and create ephemeral key\n sessionToken = generateSessionToken()\n await createSessionApiKey(em, {\n sessionToken,\n userId: auth.sub,\n userRoles: userRoleIds,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n ttlMinutes: 120,\n })\n console.log('[AI Chat] Created session token:', sessionToken.slice(0, 12) + '...')\n } catch (error) {\n console.error('[AI Chat] Failed to create session key:', error)\n // Continue without session key - tools will use static API key auth\n }\n }\n\n // Build the message to send to OpenCode\n // For NEW sessions: inject system instructions + session token\n // For existing sessions: only inject session token if available\n let messageToSend = ''\n\n // For NEW sessions only, prepend system instructions\n if (!sessionId) {\n messageToSend = `${CHAT_SYSTEM_INSTRUCTIONS}\\n\\n`\n }\n\n // If we have a session token, prepend explicit instructions for the AI to include it in tool calls\n if (sessionToken) {\n messageToSend += `[Session Authorization: ${sessionToken}. Include \"_sessionToken\": \"${sessionToken}\" in EVERY tool call.]\\n\\n`\n }\n\n messageToSend += lastUserMessage\n\n // Process in background - starts AFTER Response is returned so there's a reader for the stream\n ;(async () => {\n try {\n // Emit session-authorized event first (if we have a token)\n if (sessionToken) {\n console.log('[AI Chat] Emitting session-authorized event')\n await writeSSE({\n type: 'session-authorized',\n sessionToken: sessionToken.slice(0, 12) + '...',\n })\n }\n\n // Emit thinking event for UX feedback\n await writeSSE({ type: 'thinking' })\n\n // Use streaming handler that supports questions\n await handleOpenCodeMessageStreaming(\n {\n message: messageToSend,\n sessionId,\n },\n async (event) => {\n await writeSSE(event)\n }\n )\n } catch (error) {\n console.error('[AI Chat] OpenCode error:', error)\n await writeSSE({\n type: 'error',\n error: error instanceof Error ? error.message : 'OpenCode request failed',\n })\n } finally {\n await closeWriter()\n }\n })()\n\n return new Response(stream.readable, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n })\n } catch (error) {\n console.error('[AI Chat] Error:', error)\n return NextResponse.json({ error: 'Chat request failed' }, { status: 500 })\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAsC;AAC/C,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,OAEK;AACP,SAAS,4BAA4B;AACrC,SAAS,8BAA8B;AAEvC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;AACzB,SAAS,0BAA0B;AAMnC,MAAM,2BAA2B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["import { NextResponse, type NextRequest } from 'next/server'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport {\n handleOpenCodeMessageStreaming,\n type OpenCodeStreamEvent,\n} from '../../lib/opencode-handlers'\nimport { createOpenCodeClient } from '../../lib/opencode-client'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n generateSessionToken,\n createSessionApiKey,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\n/**\n * System instructions injected at the start of new chat sessions.\n * These ensure the AI follows the correct workflow for data operations.\n */\nconst CHAT_SYSTEM_INSTRUCTIONS = `\nYou are a helpful business assistant for Open Mercato.\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nABSOLUTE RULES \u2014 FOLLOW THESE OR BE CUT OFF\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n1. READ = GET only. If the user says find/list/show/search/get \u2192 use only GET. NEVER call PUT/POST/DELETE for a read query.\n2. 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.\n3. 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.\n4. Maximum 4 tool calls per message. Hard limit is 10.\n\nYou have 2 tools \u2014 both accept a \"code\" parameter with an async JavaScript arrow function.\n\nTOOL: search \u2014 discover endpoints and schemas (READ-ONLY, fast)\n - spec.findEndpoints(keyword) \u2192 [{ path, methods }]\n - spec.describeEndpoint(path, method) \u2192 COMPACT: { requiredFields, optionalFields, nestedCollections, example, relatedEndpoints, relatedEntity }\n - spec.describeEntity(keyword) \u2192 { className, fields, relationships }\n\nTOOL: execute \u2014 make API calls (reads and writes)\n - api.request({ method, path, query?, body? }) \u2192 { success, statusCode, data }\n\nCOMMON API PATHS (use directly \u2014 do NOT call findEndpoints for these):\n /api/customers/companies \u2014 companies (GET list, POST create, PUT update)\n /api/customers/people \u2014 contacts/people\n /api/customers/deals \u2014 deals/opportunities\n /api/customers/activities \u2014 activities/tasks\n /api/sales/orders \u2014 sales orders\n /api/sales/quotes \u2014 quotes\n /api/sales/invoices \u2014 invoices\n /api/catalog/products \u2014 products\n /api/catalog/categories \u2014 categories\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nRECIPES \u2014 follow EXACTLY for each task type\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nFIND/LIST records (1 call):\n For COMMON PATHS: skip describeEndpoint, go straight to execute.\n 1. execute: api.request({ method: 'GET', path: '/api/<module>/<resource>' })\n The \"search\" query param only matches indexed text fields \u2014 it will NOT match concepts like \"Polish\" or \"large\".\n For conceptual/subjective queries, fetch ALL records and use YOUR reasoning to identify matches from the returned data.\n For unknown paths: 1 search + 1 execute.\n\nUPDATE a record (3-4 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'PUT') \u2192 learn requestBody fields AND relatedEntity\n 2. execute: GET the record \u2192 find it, get its ID\n 3. execute: PUT to the COLLECTION path with id IN THE BODY:\n api.request({ method: 'PUT', path: '/api/<module>/<resource>', body: { id: '<uuid>', ...changes } })\n NOTE: All CRUD endpoints use the COLLECTION path. The id goes in the request BODY, not the URL. There are NO /{id} path segments.\n\nCREATE a record (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 gives requiredFields, optionalFields, nestedCollections, and a working example\n 2. Ask user for confirmation with the field values\n 3. execute: api.request({ method: 'POST', ...body })\n If the endpoint has nestedCollections (like lines), include them INLINE in the body \u2014 do NOT create them separately.\n Use the \"example\" from describeEndpoint as your template \u2014 fill in real values.\n Example \u2014 create a quote with line items:\n api.request({ method: 'POST', path: '/api/sales/quotes', body: {\n currencyCode: 'EUR', customerEntityId: '<company-uuid>',\n lines: [{ currencyCode: 'EUR', quantity: 1, productId: '<product-uuid>', name: 'Product Name', kind: 'product' }]\n }})\n Do NOT create lines separately. Do NOT include id, quoteId, or total fields \u2014 the server generates them.\n\nCREATE MULTIPLE records (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 learn fields + example\n 2. execute: loop in one call:\n async () => {\n const results = [];\n for (const item of items) {\n results.push(await api.request({ method: 'POST', path: '...', body: item }));\n }\n return results;\n }\n\nDISCOVER (what endpoints/entities exist) (1 call):\n 1. search: spec.findEndpoints('<keyword>') or spec.describeEntity('<keyword>')\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nHARD RULES\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n- MAXIMUM 4 tool calls per user message. You WILL be cut off after 10.\n- NEVER call findEndpoints or describeEndpoint for COMMON PATHS listed above \u2014 use them directly with execute.\n- NEVER call describeEntity if describeEndpoint already returned relatedEntity.\n- NEVER repeat a search from earlier in the conversation \u2014 reuse previous results.\n- NEVER make N+1 API calls (1 call per record). Fetch a list and reason about the results yourself.\n- When you already have the data you need from a previous call, use it \u2014 do NOT fetch more data to \"enrich\" it.\n- 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.\n- The \"search\" query param is fulltext only \u2014 it won't match nationalities, categories, or subjective criteria. For those, fetch all and reason.\n- describeEndpoint returns a COMPACT summary with requiredFields, optionalFields, and an example. Use the example as your template \u2014 fill in real values and send it.\n- For fields you don't know, OMIT them \u2014 the API uses defaults for optional fields.\n- NEVER try to set computed/total fields (amounts, totals, counts) \u2014 the server calculates them.\n- For updates: describeEndpoint gives you the field names. Go straight to GET + PUT. PUT path is the COLLECTION path, id in BODY.\n- For creates with children (e.g. quote + lines): include children INLINE in the body using the nestedCollections field name.\n\nRESPONSE RULES:\n- Be proactive \u2014 fetch data and present results, don't ask what the user wants to see.\n- Never show technical terms, IDs, JSON, or internal reasoning.\n- Present results in clean business language with **bold names** and bullet points.\n- Only ask for confirmation before create/update/delete operations.\n`.trim()\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\n/**\n * Get user's role IDs from the database.\n */\nasync function getUserRoleIds(\n em: EntityManager,\n userId: string,\n tenantId: string | null\n): Promise<string[]> {\n if (!tenantId) return []\n\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n return linkList\n .map((l) => (l.role as any)?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n}\n\n/**\n * Chat endpoint that routes messages to OpenCode agent.\n * OpenCode connects to MCP server for tool access (search, execute, context_whoami).\n *\n * Emits verbose SSE events for debugging:\n * - thinking: Agent started processing\n * - metadata: Model, tokens, timing info\n * - tool-call: Tool invocation with args\n * - tool-result: Tool response\n * - text: Response text\n * - question: Confirmation question from agent\n * - done: Complete with session ID\n * - error: Error occurred\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const body = await req.json()\n const { messages, sessionId, answerQuestion } = body as {\n messages?: Array<{ role: string; content: string }>\n sessionId?: string\n // For answering a question\n answerQuestion?: {\n questionId: string\n answer: number\n sessionId: string\n }\n }\n\n // Create SSE stream for frontend compatibility\n const encoder = new TextEncoder()\n const stream = new TransformStream()\n const writer = stream.writable.getWriter()\n let writerClosed = false\n\n const writeSSE = async (event: OpenCodeStreamEvent | { type: string; [key: string]: unknown }) => {\n if (writerClosed) return // Guard against writes after close\n try {\n const jsonStr = JSON.stringify(event)\n await writer.write(encoder.encode(`data: ${jsonStr}\\n\\n`))\n } catch (err) {\n // Writer may have been closed by client disconnect\n console.warn('[AI Chat] Failed to write SSE event:', event.type)\n }\n }\n\n const closeWriter = async () => {\n if (writerClosed) return\n writerClosed = true\n try {\n await writer.close()\n } catch {\n // Already closed\n }\n }\n\n // Handle question answer - simple JSON response, not SSE\n // The original SSE stream continues and will receive the follow-up response\n if (answerQuestion) {\n try {\n const client = createOpenCodeClient()\n await client.answerQuestion(answerQuestion.questionId, answerQuestion.answer)\n return NextResponse.json({ success: true })\n } catch (error) {\n console.error('[AI Chat] Answer error:', error)\n return NextResponse.json(\n { error: error instanceof Error ? error.message : 'Failed to answer question' },\n { status: 500 }\n )\n }\n }\n\n // Handle regular message\n if (!messages || !Array.isArray(messages)) {\n return NextResponse.json({ error: 'messages array is required' }, { status: 400 })\n }\n\n // Get the latest user message\n const lastUserMessage = messages.filter((m) => m.role === 'user').pop()?.content\n if (!lastUserMessage) {\n return NextResponse.json({ error: 'No user message found' }, { status: 400 })\n }\n\n const chatStartTime = Date.now()\n const messagePreview = lastUserMessage.slice(0, 80).replace(/\\n/g, ' ')\n console.error(`[AI Usage] Chat request: user=${auth.sub} session=${sessionId ? sessionId.slice(0, 16) + '...' : 'new'} message=\"${messagePreview}${lastUserMessage.length > 80 ? '...' : ''}\"`)\n\n\n // For new sessions, create an ephemeral API key that inherits user permissions\n // The API key secret is encrypted and stored; MCP server recovers it via session token\n let sessionToken: string | null = null\n if (!sessionId) {\n try {\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n // Get user's role IDs from database\n const userRoleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n // Generate session token and create ephemeral key\n sessionToken = generateSessionToken()\n await createSessionApiKey(em, {\n sessionToken,\n userId: auth.sub,\n userRoles: userRoleIds,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n ttlMinutes: 120,\n })\n console.log('[AI Chat] Created session token:', sessionToken.slice(0, 12) + '...')\n } catch (error) {\n console.error('[AI Chat] Failed to create session key:', error)\n // Continue without session key - tools will use static API key auth\n }\n }\n\n // Build the message to send to OpenCode\n // For NEW sessions: inject system instructions + session token\n // For existing sessions: only inject session token if available\n let messageToSend = ''\n\n // For NEW sessions only, prepend system instructions\n if (!sessionId) {\n messageToSend = `${CHAT_SYSTEM_INSTRUCTIONS}\\n\\n`\n }\n\n // If we have a session token, prepend explicit instructions for the AI to include it in tool calls\n if (sessionToken) {\n messageToSend += `[Session Authorization: ${sessionToken}. Include \"_sessionToken\": \"${sessionToken}\" in EVERY tool call.]\\n\\n`\n }\n\n messageToSend += lastUserMessage\n\n // Process in background - starts AFTER Response is returned so there's a reader for the stream\n ;(async () => {\n let toolCallCount = 0\n let lastTokens: { input?: number; output?: number } | undefined\n let resultSessionId: string | undefined\n\n try {\n // Emit session-authorized event first (if we have a token)\n if (sessionToken) {\n console.log('[AI Chat] Emitting session-authorized event')\n await writeSSE({\n type: 'session-authorized',\n sessionToken: sessionToken.slice(0, 12) + '...',\n })\n }\n\n // Emit thinking event for UX feedback\n await writeSSE({ type: 'thinking' })\n\n // Use streaming handler that supports questions\n await handleOpenCodeMessageStreaming(\n {\n message: messageToSend,\n sessionId,\n },\n async (event) => {\n // Track usage from stream events\n if (event.type === 'tool-call') toolCallCount++\n if (event.type === 'metadata' && 'tokens' in event) lastTokens = event.tokens\n if (event.type === 'done' && 'sessionId' in event) resultSessionId = event.sessionId\n\n await writeSSE(event)\n }\n )\n } catch (error) {\n console.error('[AI Chat] OpenCode error:', error)\n await writeSSE({\n type: 'error',\n error: error instanceof Error ? error.message : 'OpenCode request failed',\n })\n } finally {\n const durationMs = Date.now() - chatStartTime\n 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}}` : ''}`)\n await closeWriter()\n }\n })()\n\n return new Response(stream.readable, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n })\n } catch (error) {\n console.error('[AI Chat] Error:', error)\n return NextResponse.json({ error: 'Chat request failed' }, { status: 500 })\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAsC;AAC/C,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,OAEK;AACP,SAAS,4BAA4B;AACrC,SAAS,8BAA8B;AAEvC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;AACzB,SAAS,0BAA0B;AAMnC,MAAM,2BAA2B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmG/B,KAAK;AAEA,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACpE;AAKA,eAAe,eACb,IACA,QACA,UACmB;AACnB,MAAI,CAAC,SAAU,QAAO,CAAC;AAEvB,QAAM,QAAQ,MAAM;AAAA,IAClB;AAAA,IACA;AAAA,IACA,EAAE,MAAM,QAAe,MAAM,EAAE,SAAS,EAAE;AAAA,IAC1C,EAAE,UAAU,CAAC,MAAM,EAAE;AAAA,IACrB,EAAE,UAAU,gBAAgB,KAAK;AAAA,EACnC;AACA,QAAM,WAAW,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC;AACjD,SAAO,SACJ,IAAI,CAAC,MAAO,EAAE,MAAc,EAAE,EAC9B,OAAO,CAAC,OAAqB,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC;AACzE;AAgBA,eAAsB,KAAK,KAAkB;AAC3C,QAAM,OAAO,MAAM,mBAAmB,GAAG;AAEzC,MAAI,CAAC,MAAM;AACT,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACF,UAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,UAAM,EAAE,UAAU,WAAW,eAAe,IAAI;AAYhD,UAAM,UAAU,IAAI,YAAY;AAChC,UAAM,SAAS,IAAI,gBAAgB;AACnC,UAAM,SAAS,OAAO,SAAS,UAAU;AACzC,QAAI,eAAe;AAEnB,UAAM,WAAW,OAAO,UAA0E;AAChG,UAAI,aAAc;AAClB,UAAI;AACF,cAAM,UAAU,KAAK,UAAU,KAAK;AACpC,cAAM,OAAO,MAAM,QAAQ,OAAO,SAAS,OAAO;AAAA;AAAA,CAAM,CAAC;AAAA,MAC3D,SAAS,KAAK;AAEZ,gBAAQ,KAAK,wCAAwC,MAAM,IAAI;AAAA,MACjE;AAAA,IACF;AAEA,UAAM,cAAc,YAAY;AAC9B,UAAI,aAAc;AAClB,qBAAe;AACf,UAAI;AACF,cAAM,OAAO,MAAM;AAAA,MACrB,QAAQ;AAAA,MAER;AAAA,IACF;AAIA,QAAI,gBAAgB;AAClB,UAAI;AACF,cAAM,SAAS,qBAAqB;AACpC,cAAM,OAAO,eAAe,eAAe,YAAY,eAAe,MAAM;AAC5E,eAAO,aAAa,KAAK,EAAE,SAAS,KAAK,CAAC;AAAA,MAC5C,SAAS,OAAO;AACd,gBAAQ,MAAM,2BAA2B,KAAK;AAC9C,eAAO,aAAa;AAAA,UAClB,EAAE,OAAO,iBAAiB,QAAQ,MAAM,UAAU,4BAA4B;AAAA,UAC9E,EAAE,QAAQ,IAAI;AAAA,QAChB;AAAA,MACF;AAAA,IACF;AAGA,QAAI,CAAC,YAAY,CAAC,MAAM,QAAQ,QAAQ,GAAG;AACzC,aAAO,aAAa,KAAK,EAAE,OAAO,6BAA6B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACnF;AAGA,UAAM,kBAAkB,SAAS,OAAO,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,IAAI,GAAG;AACzE,QAAI,CAAC,iBAAiB;AACpB,aAAO,aAAa,KAAK,EAAE,OAAO,wBAAwB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC9E;AAEA,UAAM,gBAAgB,KAAK,IAAI;AAC/B,UAAM,iBAAiB,gBAAgB,MAAM,GAAG,EAAE,EAAE,QAAQ,OAAO,GAAG;AACtE,YAAQ,MAAM,iCAAiC,KAAK,GAAG,YAAY,YAAY,UAAU,MAAM,GAAG,EAAE,IAAI,QAAQ,KAAK,aAAa,cAAc,GAAG,gBAAgB,SAAS,KAAK,QAAQ,EAAE,GAAG;AAK9L,QAAI,eAA8B;AAClC,QAAI,CAAC,WAAW;AACd,UAAI;AACF,cAAM,YAAY,MAAM,uBAAuB;AAC/C,cAAM,KAAK,UAAU,QAAuB,IAAI;AAGhD,cAAM,cAAc,MAAM,eAAe,IAAI,KAAK,KAAK,KAAK,QAAQ;AAGpE,uBAAe,qBAAqB;AACpC,cAAM,oBAAoB,IAAI;AAAA,UAC5B;AAAA,UACA,QAAQ,KAAK;AAAA,UACb,WAAW;AAAA,UACX,UAAU,KAAK;AAAA,UACf,gBAAgB,KAAK;AAAA,UACrB,YAAY;AAAA,QACd,CAAC;AACD,gBAAQ,IAAI,oCAAoC,aAAa,MAAM,GAAG,EAAE,IAAI,KAAK;AAAA,MACnF,SAAS,OAAO;AACd,gBAAQ,MAAM,2CAA2C,KAAK;AAAA,MAEhE;AAAA,IACF;AAKA,QAAI,gBAAgB;AAGpB,QAAI,CAAC,WAAW;AACd,sBAAgB,GAAG,wBAAwB;AAAA;AAAA;AAAA,IAC7C;AAGA,QAAI,cAAc;AAChB,uBAAiB,2BAA2B,YAAY,+BAA+B,YAAY;AAAA;AAAA;AAAA,IACrG;AAEA,qBAAiB;AAGhB,KAAC,YAAY;AACZ,UAAI,gBAAgB;AACpB,UAAI;AACJ,UAAI;AAEJ,UAAI;AAEF,YAAI,cAAc;AAChB,kBAAQ,IAAI,6CAA6C;AACzD,gBAAM,SAAS;AAAA,YACb,MAAM;AAAA,YACN,cAAc,aAAa,MAAM,GAAG,EAAE,IAAI;AAAA,UAC5C,CAAC;AAAA,QACH;AAGA,cAAM,SAAS,EAAE,MAAM,WAAW,CAAC;AAGnC,cAAM;AAAA,UACJ;AAAA,YACE,SAAS;AAAA,YACT;AAAA,UACF;AAAA,UACA,OAAO,UAAU;AAEf,gBAAI,MAAM,SAAS,YAAa;AAChC,gBAAI,MAAM,SAAS,cAAc,YAAY,MAAO,cAAa,MAAM;AACvE,gBAAI,MAAM,SAAS,UAAU,eAAe,MAAO,mBAAkB,MAAM;AAE3E,kBAAM,SAAS,KAAK;AAAA,UACtB;AAAA,QACF;AAAA,MACF,SAAS,OAAO;AACd,gBAAQ,MAAM,6BAA6B,KAAK;AAChD,cAAM,SAAS;AAAA,UACb,MAAM;AAAA,UACN,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,QAClD,CAAC;AAAA,MACH,UAAE;AACA,cAAM,aAAa,KAAK,IAAI,IAAI;AAChC,gBAAQ,MAAM,kCAAkC,KAAK,GAAG,aAAa,mBAAmB,aAAa,WAAW,MAAM,GAAG,EAAE,CAAC,gBAAgB,UAAU,gBAAgB,aAAa,GAAG,aAAa,eAAe,WAAW,SAAS,CAAC,QAAQ,WAAW,UAAU,CAAC,MAAM,EAAE,EAAE;AAC/Q,cAAM,YAAY;AAAA,MACpB;AAAA,IACF,GAAG;AAEH,WAAO,IAAI,SAAS,OAAO,UAAU;AAAA,MACnC,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,iBAAiB;AAAA,QACjB,YAAY;AAAA,MACd;AAAA,IACF,CAAC;AAAA,EACH,SAAS,OAAO;AACd,YAAQ,MAAM,oBAAoB,KAAK;AACvC,WAAO,aAAa,KAAK,EAAE,OAAO,sBAAsB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC5E;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
const API_ENDPOINT_ENTITY = API_ENDPOINT_ENTITY_ID;
|
|
10
10
|
let endpointsCache = null;
|
|
11
11
|
let endpointsByOperationId = null;
|
|
12
|
+
let rawSpecCache = null;
|
|
12
13
|
async function getApiEndpoints() {
|
|
13
14
|
if (endpointsCache) {
|
|
14
15
|
return endpointsCache;
|
|
@@ -21,6 +22,97 @@ async function getEndpointByOperationId(operationId) {
|
|
|
21
22
|
await getApiEndpoints();
|
|
22
23
|
return endpointsByOperationId?.get(operationId) ?? null;
|
|
23
24
|
}
|
|
25
|
+
async function getRawOpenApiSpec() {
|
|
26
|
+
if (rawSpecCache) return rawSpecCache;
|
|
27
|
+
rawSpecCache = await loadRawOpenApiSpec();
|
|
28
|
+
return rawSpecCache;
|
|
29
|
+
}
|
|
30
|
+
function setRawSpecCache(doc) {
|
|
31
|
+
rawSpecCache = doc;
|
|
32
|
+
}
|
|
33
|
+
function clearRawSpecCache() {
|
|
34
|
+
rawSpecCache = null;
|
|
35
|
+
}
|
|
36
|
+
async function loadRichOpenApiSpec() {
|
|
37
|
+
if (rawSpecCache) return rawSpecCache;
|
|
38
|
+
try {
|
|
39
|
+
const { getModules } = await import("@open-mercato/shared/lib/modules/registry");
|
|
40
|
+
const modules = getModules();
|
|
41
|
+
const modulesWithApis = modules.filter((m) => m.apis && m.apis.length > 0);
|
|
42
|
+
if (modulesWithApis.length > 0) {
|
|
43
|
+
const doc = buildOpenApiDocument(modules, {
|
|
44
|
+
title: "Open Mercato API",
|
|
45
|
+
version: "1.0.0",
|
|
46
|
+
servers: [{ url: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" }]
|
|
47
|
+
});
|
|
48
|
+
console.error(`[API Index] Rich OpenAPI spec built from ${modulesWithApis.length} modules (Tier 2)`);
|
|
49
|
+
rawSpecCache = doc;
|
|
50
|
+
return doc;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
rawSpecCache = await loadRawOpenApiSpec();
|
|
55
|
+
return rawSpecCache;
|
|
56
|
+
}
|
|
57
|
+
async function loadRawOpenApiSpec() {
|
|
58
|
+
try {
|
|
59
|
+
const fs = await import("node:fs");
|
|
60
|
+
const path = await import("node:path");
|
|
61
|
+
const { findAppRoot, findAllApps } = await import("@open-mercato/shared/lib/bootstrap/appResolver");
|
|
62
|
+
let appRoot = findAppRoot();
|
|
63
|
+
if (!appRoot) {
|
|
64
|
+
let current = process.cwd();
|
|
65
|
+
while (current !== path.dirname(current)) {
|
|
66
|
+
const appsDir = path.join(current, "apps");
|
|
67
|
+
if (fs.existsSync(appsDir)) {
|
|
68
|
+
const apps = findAllApps(current);
|
|
69
|
+
if (apps.length > 0) {
|
|
70
|
+
appRoot = apps[0];
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
current = path.dirname(current);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (appRoot) {
|
|
78
|
+
const jsonPath = path.join(appRoot.generatedDir, "openapi.generated.json");
|
|
79
|
+
if (fs.existsSync(jsonPath)) {
|
|
80
|
+
const doc = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
|
81
|
+
console.error(`[API Index] Raw OpenAPI spec loaded from ${jsonPath}`);
|
|
82
|
+
return doc;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error("[API Index] Raw spec from JSON failed:", error instanceof Error ? error.message : error);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const { getModules } = await import("@open-mercato/shared/lib/modules/registry");
|
|
90
|
+
const modules = getModules();
|
|
91
|
+
const modulesWithApis = modules.filter((m) => m.apis && m.apis.length > 0);
|
|
92
|
+
if (modulesWithApis.length > 0) {
|
|
93
|
+
const doc = buildOpenApiDocument(modules, {
|
|
94
|
+
title: "Open Mercato API",
|
|
95
|
+
version: "1.0.0",
|
|
96
|
+
servers: [{ url: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" }]
|
|
97
|
+
});
|
|
98
|
+
console.error(`[API Index] Raw OpenAPI spec built from ${modulesWithApis.length} modules`);
|
|
99
|
+
return doc;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || "http://localhost:3000";
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(`${baseUrl}/api/docs/openapi`);
|
|
106
|
+
if (response.ok) {
|
|
107
|
+
const doc = await response.json();
|
|
108
|
+
console.error("[API Index] Raw OpenAPI spec fetched via HTTP");
|
|
109
|
+
return doc;
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error("[API Index] Raw spec HTTP fetch failed:", error instanceof Error ? error.message : error);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
24
116
|
async function parseApiEndpointsFromGeneratedJson() {
|
|
25
117
|
try {
|
|
26
118
|
const fs = await import("node:fs");
|
|
@@ -270,6 +362,7 @@ function searchEndpointsFallback(query, options = {}) {
|
|
|
270
362
|
function clearEndpointCache() {
|
|
271
363
|
endpointsCache = null;
|
|
272
364
|
endpointsByOperationId = null;
|
|
365
|
+
rawSpecCache = null;
|
|
273
366
|
}
|
|
274
367
|
function simplifyRequestBodySchema(schema) {
|
|
275
368
|
if (!schema) return null;
|
|
@@ -293,10 +386,14 @@ function simplifyRequestBodySchema(schema) {
|
|
|
293
386
|
export {
|
|
294
387
|
API_ENDPOINT_ENTITY,
|
|
295
388
|
clearEndpointCache,
|
|
389
|
+
clearRawSpecCache,
|
|
296
390
|
getApiEndpoints,
|
|
297
391
|
getEndpointByOperationId,
|
|
392
|
+
getRawOpenApiSpec,
|
|
298
393
|
indexApiEndpoints,
|
|
394
|
+
loadRichOpenApiSpec,
|
|
299
395
|
searchEndpoints,
|
|
396
|
+
setRawSpecCache,
|
|
300
397
|
simplifyRequestBodySchema
|
|
301
398
|
};
|
|
302
399
|
//# sourceMappingURL=api-endpoint-index.js.map
|