@open-mercato/ai-assistant 0.4.9-develop-de525127d6 → 0.4.9-develop-31d1a87765
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { registerMcpTool } from "./tool-registry.js";
|
|
3
|
+
import { createSandbox } from "./sandbox.js";
|
|
4
|
+
import { truncateResult } from "./truncate.js";
|
|
5
|
+
import { getRawOpenApiSpec } from "./api-endpoint-index.js";
|
|
6
|
+
import {
|
|
7
|
+
getCachedEntityGraph,
|
|
8
|
+
inferModuleFromEntity
|
|
9
|
+
} from "./entity-graph.js";
|
|
10
|
+
import {
|
|
11
|
+
lookupSearchCache,
|
|
12
|
+
storeSearchResult,
|
|
13
|
+
buildMemoryContext,
|
|
14
|
+
buildSearchLabel,
|
|
15
|
+
incrementToolCallCount
|
|
16
|
+
} from "./session-memory.js";
|
|
17
|
+
let cachedCodeModeSpec = null;
|
|
18
|
+
let cachedCommonTypes = null;
|
|
19
|
+
async function getCodeModeSpec() {
|
|
20
|
+
if (cachedCodeModeSpec) return cachedCodeModeSpec;
|
|
21
|
+
const rawSpec = await getRawOpenApiSpec();
|
|
22
|
+
const graph = getCachedEntityGraph();
|
|
23
|
+
const paths = rawSpec?.paths ?? {};
|
|
24
|
+
const entitySchemas = graph ? buildEntitySchemas(graph) : [];
|
|
25
|
+
const spec = {
|
|
26
|
+
paths,
|
|
27
|
+
info: rawSpec?.info,
|
|
28
|
+
components: rawSpec?.components,
|
|
29
|
+
entitySchemas
|
|
30
|
+
};
|
|
31
|
+
spec.findEndpoints = (keyword) => {
|
|
32
|
+
const kw = keyword.toLowerCase();
|
|
33
|
+
return Object.entries(paths).filter(([path]) => path.toLowerCase().includes(kw)).map(([path, methods]) => ({
|
|
34
|
+
path,
|
|
35
|
+
methods: Object.keys(methods).filter((m) => m !== "parameters")
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
38
|
+
spec.describeEndpoint = (path, method) => {
|
|
39
|
+
const pathObj = paths[path];
|
|
40
|
+
if (!pathObj) return null;
|
|
41
|
+
const endpoint = pathObj[method.toLowerCase()];
|
|
42
|
+
if (!endpoint) return null;
|
|
43
|
+
const bodySchema = extractRequestBodySchema(endpoint);
|
|
44
|
+
const bodyProps = bodySchema?.properties ?? {};
|
|
45
|
+
const bodyRequired = bodySchema?.required ?? [];
|
|
46
|
+
const requiredFields = [];
|
|
47
|
+
const optionalFields = [];
|
|
48
|
+
const nestedCollections = [];
|
|
49
|
+
for (const [name, prop] of Object.entries(bodyProps)) {
|
|
50
|
+
const propType = prop.type || "string";
|
|
51
|
+
if (propType === "array" && prop.items && prop.items.type === "object") {
|
|
52
|
+
const itemSchema = prop.items;
|
|
53
|
+
const itemProps = itemSchema.properties ?? {};
|
|
54
|
+
const itemRequired = itemSchema.required ?? [];
|
|
55
|
+
const nestedRequired = itemRequired.map((n) => ({
|
|
56
|
+
name: n,
|
|
57
|
+
type: itemProps[n]?.type || "string"
|
|
58
|
+
}));
|
|
59
|
+
const nestedOptional = Object.keys(itemProps).filter((n) => !itemRequired.includes(n));
|
|
60
|
+
const commonFields = nestedOptional.slice(0, 6);
|
|
61
|
+
nestedCollections.push({
|
|
62
|
+
field: name,
|
|
63
|
+
type: "array",
|
|
64
|
+
requiredFields: nestedRequired,
|
|
65
|
+
commonFields
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (bodyRequired.includes(name)) {
|
|
70
|
+
const field = { name, type: propType };
|
|
71
|
+
if (prop.format) field.format = prop.format;
|
|
72
|
+
requiredFields.push(field);
|
|
73
|
+
} else {
|
|
74
|
+
optionalFields.push(name);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const example = {};
|
|
78
|
+
for (const field of requiredFields) {
|
|
79
|
+
example[field.name] = generatePlaceholder(field.type, field.format);
|
|
80
|
+
}
|
|
81
|
+
for (const collection of nestedCollections) {
|
|
82
|
+
const itemExample = {};
|
|
83
|
+
for (const field of collection.requiredFields) {
|
|
84
|
+
itemExample[field.name] = generatePlaceholder(field.type);
|
|
85
|
+
}
|
|
86
|
+
for (const name of collection.commonFields.slice(0, 2)) {
|
|
87
|
+
itemExample[name] = "<value>";
|
|
88
|
+
}
|
|
89
|
+
example[collection.field] = [itemExample];
|
|
90
|
+
}
|
|
91
|
+
const segments = path.replace("/api/", "").split("/");
|
|
92
|
+
const moduleSegment = segments[0];
|
|
93
|
+
const resourceName = segments[1] || segments[0];
|
|
94
|
+
const modulePrefix = `/api/${moduleSegment}/`;
|
|
95
|
+
const relatedEndpoints = Object.entries(paths).filter(([p]) => p.startsWith(modulePrefix) && p !== path && !p.includes("{")).map(([p, methods]) => ({
|
|
96
|
+
path: p,
|
|
97
|
+
methods: Object.keys(methods).filter((m) => m !== "parameters")
|
|
98
|
+
})).slice(0, 8);
|
|
99
|
+
const resourceNorm = resourceName.replace(/-/g, "_");
|
|
100
|
+
const resourceSingular = resourceNorm.endsWith("s") ? resourceNorm.slice(0, -1) : resourceNorm;
|
|
101
|
+
const moduleSingular = moduleSegment.endsWith("s") ? moduleSegment.slice(0, -1) : moduleSegment;
|
|
102
|
+
const prefixedTable = `${moduleSingular}_${resourceNorm}`;
|
|
103
|
+
const entity = entitySchemas.find((e) => {
|
|
104
|
+
const table = (e.tableName || "").toLowerCase();
|
|
105
|
+
const cls = (e.className || "").toLowerCase();
|
|
106
|
+
const mod = (e.module || "").toLowerCase();
|
|
107
|
+
if (table === resourceNorm || table === prefixedTable) return true;
|
|
108
|
+
if (cls.includes(moduleSingular) && cls.includes(resourceSingular)) return true;
|
|
109
|
+
if (mod === moduleSegment && cls.includes(resourceSingular)) return true;
|
|
110
|
+
if (cls === resourceSingular || cls.includes(resourceSingular)) return true;
|
|
111
|
+
return false;
|
|
112
|
+
}) || null;
|
|
113
|
+
let relatedEntity = null;
|
|
114
|
+
if (entity) {
|
|
115
|
+
const ent = entity;
|
|
116
|
+
const rels = ent.relationships || [];
|
|
117
|
+
const relSummary = rels.map((r) => `${r.relationship}: ${r.target}`).join(", ");
|
|
118
|
+
relatedEntity = `${ent.className}${relSummary ? ` (${relSummary})` : ""}`;
|
|
119
|
+
}
|
|
120
|
+
const parameters = method.toLowerCase() === "get" ? (endpoint.parameters || []).filter((p) => p.in === "query").map((p) => p.name) : void 0;
|
|
121
|
+
return {
|
|
122
|
+
path,
|
|
123
|
+
method: method.toUpperCase(),
|
|
124
|
+
summary: endpoint.summary || endpoint.description,
|
|
125
|
+
...parameters && parameters.length > 0 ? { queryParams: parameters } : {},
|
|
126
|
+
...requiredFields.length > 0 ? { requiredFields } : {},
|
|
127
|
+
...optionalFields.length > 0 ? { optionalFields } : {},
|
|
128
|
+
...nestedCollections.length > 0 ? { nestedCollections } : {},
|
|
129
|
+
...Object.keys(example).length > 0 ? { example } : {},
|
|
130
|
+
...relatedEndpoints.length > 0 ? { relatedEndpoints } : {},
|
|
131
|
+
relatedEntity
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
spec.describeEntity = (keyword) => {
|
|
135
|
+
const kw = keyword.toLowerCase();
|
|
136
|
+
return entitySchemas.find((e) => {
|
|
137
|
+
const cls = (e.className || "").toLowerCase();
|
|
138
|
+
const table = (e.tableName || "").toLowerCase();
|
|
139
|
+
return cls.includes(kw) || table.includes(kw);
|
|
140
|
+
}) || null;
|
|
141
|
+
};
|
|
142
|
+
cachedCodeModeSpec = spec;
|
|
143
|
+
return spec;
|
|
144
|
+
}
|
|
145
|
+
function extractRequestBodySchema(endpoint) {
|
|
146
|
+
const requestBody = endpoint.requestBody;
|
|
147
|
+
if (!requestBody) return null;
|
|
148
|
+
const content = requestBody.content;
|
|
149
|
+
if (!content) return null;
|
|
150
|
+
const jsonContent = content["application/json"];
|
|
151
|
+
if (!jsonContent) return null;
|
|
152
|
+
return jsonContent.schema || null;
|
|
153
|
+
}
|
|
154
|
+
function generatePlaceholder(type, format) {
|
|
155
|
+
if (format === "uuid" || format === "objectId") return "<uuid>";
|
|
156
|
+
if (format === "date-time" || format === "date") return "<date>";
|
|
157
|
+
if (format === "email") return "<email>";
|
|
158
|
+
switch (type) {
|
|
159
|
+
case "string":
|
|
160
|
+
return "<string>";
|
|
161
|
+
case "number":
|
|
162
|
+
case "integer":
|
|
163
|
+
return 0;
|
|
164
|
+
case "boolean":
|
|
165
|
+
return false;
|
|
166
|
+
case "array":
|
|
167
|
+
return [];
|
|
168
|
+
default:
|
|
169
|
+
return "<value>";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const COMMON_ENDPOINTS = [
|
|
173
|
+
{ path: "/api/sales/quotes", method: "post", typeName: "CreateQuote" },
|
|
174
|
+
{ path: "/api/sales/orders", method: "post", typeName: "CreateOrder" },
|
|
175
|
+
{ path: "/api/sales/invoices", method: "post", typeName: "CreateInvoice" },
|
|
176
|
+
{ path: "/api/customers/companies", method: "post", typeName: "CreateCompany" },
|
|
177
|
+
{ path: "/api/customers/people", method: "post", typeName: "CreatePerson" },
|
|
178
|
+
{ path: "/api/customers/deals", method: "post", typeName: "CreateDeal" },
|
|
179
|
+
{ path: "/api/catalog/products", method: "post", typeName: "CreateProduct" },
|
|
180
|
+
{ path: "/api/customers/companies", method: "put", typeName: "UpdateCompany" },
|
|
181
|
+
{ path: "/api/customers/people", method: "put", typeName: "UpdatePerson" },
|
|
182
|
+
{ path: "/api/sales/quotes", method: "put", typeName: "UpdateQuote" }
|
|
183
|
+
];
|
|
184
|
+
async function generateCommonTypes() {
|
|
185
|
+
if (cachedCommonTypes) return cachedCommonTypes;
|
|
186
|
+
const rawSpec = await getRawOpenApiSpec();
|
|
187
|
+
if (!rawSpec?.paths) {
|
|
188
|
+
cachedCommonTypes = "";
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
const paths = rawSpec.paths;
|
|
192
|
+
const typeLines = ["Available types for api.request() body:\n"];
|
|
193
|
+
for (const { path, method, typeName } of COMMON_ENDPOINTS) {
|
|
194
|
+
const pathObj = paths[path];
|
|
195
|
+
if (!pathObj) continue;
|
|
196
|
+
const endpoint = pathObj[method];
|
|
197
|
+
if (!endpoint) continue;
|
|
198
|
+
const bodySchema = extractRequestBodySchema(endpoint);
|
|
199
|
+
if (!bodySchema?.properties) continue;
|
|
200
|
+
const typeStr = schemaToTypeString(
|
|
201
|
+
typeName,
|
|
202
|
+
bodySchema,
|
|
203
|
+
`${method.toUpperCase()} ${path}`
|
|
204
|
+
);
|
|
205
|
+
if (typeStr) typeLines.push(typeStr);
|
|
206
|
+
}
|
|
207
|
+
if (typeLines.length <= 1) {
|
|
208
|
+
cachedCommonTypes = "";
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
cachedCommonTypes = typeLines.join("\n");
|
|
212
|
+
console.error(`[Code Mode] Generated ${typeLines.length - 1} common type stubs`);
|
|
213
|
+
return cachedCommonTypes;
|
|
214
|
+
}
|
|
215
|
+
function schemaToTypeString(typeName, schema, comment) {
|
|
216
|
+
const props = schema.properties;
|
|
217
|
+
if (!props) return null;
|
|
218
|
+
const required = new Set(schema.required || []);
|
|
219
|
+
const skipFields = /* @__PURE__ */ new Set(["tenantId", "organizationId"]);
|
|
220
|
+
const fields = [];
|
|
221
|
+
const nestedTypes = [];
|
|
222
|
+
for (const [name, prop] of Object.entries(props)) {
|
|
223
|
+
if (skipFields.has(name)) continue;
|
|
224
|
+
if (!prop || typeof prop !== "object") continue;
|
|
225
|
+
const isRequired = required.has(name);
|
|
226
|
+
const optMark = isRequired ? "" : "?";
|
|
227
|
+
if (prop.type === "array" && prop.items && prop.items.type === "object") {
|
|
228
|
+
const itemTypeName = `${typeName}${capitalize(singularize(name))}`;
|
|
229
|
+
const itemSchema = prop.items;
|
|
230
|
+
const nestedType = schemaToTypeString(itemTypeName, itemSchema, "");
|
|
231
|
+
if (nestedType) nestedTypes.push(nestedType);
|
|
232
|
+
fields.push(`${name}${optMark}: ${itemTypeName}[]`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const propType = resolvePropertyType(prop);
|
|
236
|
+
fields.push(`${name}${optMark}: ${propType}`);
|
|
237
|
+
}
|
|
238
|
+
if (fields.length === 0) return null;
|
|
239
|
+
const commentLine = comment ? `// ${comment}
|
|
240
|
+
` : "";
|
|
241
|
+
const nested = nestedTypes.length > 0 ? nestedTypes.join("\n") + "\n" : "";
|
|
242
|
+
return `${nested}${commentLine}type ${typeName} = { ${fields.join("; ")} }`;
|
|
243
|
+
}
|
|
244
|
+
function resolvePropertyType(prop) {
|
|
245
|
+
if (prop.anyOf && Array.isArray(prop.anyOf)) {
|
|
246
|
+
const variants = prop.anyOf.filter(
|
|
247
|
+
(s) => s != null
|
|
248
|
+
);
|
|
249
|
+
const nonNull = variants.filter((s) => s.type !== "null");
|
|
250
|
+
if (nonNull.length === 1) {
|
|
251
|
+
return resolvePropertyType(nonNull[0]) + " | null";
|
|
252
|
+
}
|
|
253
|
+
if (nonNull.length > 1) {
|
|
254
|
+
return nonNull.map((s) => resolvePropertyType(s)).join(" | ");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (prop.enum && Array.isArray(prop.enum)) {
|
|
258
|
+
return prop.enum.map((v) => `'${v}'`).join(" | ");
|
|
259
|
+
}
|
|
260
|
+
const type = prop.type;
|
|
261
|
+
const format = prop.format;
|
|
262
|
+
if (type === "array") {
|
|
263
|
+
const items = prop.items;
|
|
264
|
+
if (items) return `${resolvePropertyType(items)}[]`;
|
|
265
|
+
return "unknown[]";
|
|
266
|
+
}
|
|
267
|
+
if (type === "object") return "object";
|
|
268
|
+
if (format === "uuid") return "string /*uuid*/";
|
|
269
|
+
if (format === "date-time") return "string /*ISO date*/";
|
|
270
|
+
if (format === "date") return "string /*date*/";
|
|
271
|
+
if (format === "email") return "string /*email*/";
|
|
272
|
+
switch (type) {
|
|
273
|
+
case "string":
|
|
274
|
+
return "string";
|
|
275
|
+
case "number":
|
|
276
|
+
case "integer":
|
|
277
|
+
return "number";
|
|
278
|
+
case "boolean":
|
|
279
|
+
return "boolean";
|
|
280
|
+
default:
|
|
281
|
+
return "unknown";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function capitalize(s) {
|
|
285
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
286
|
+
}
|
|
287
|
+
function singularize(s) {
|
|
288
|
+
if (s.endsWith("ies")) return s.slice(0, -3) + "y";
|
|
289
|
+
if (s.endsWith("ses")) return s.slice(0, -2);
|
|
290
|
+
if (s.endsWith("s") && !s.endsWith("ss")) return s.slice(0, -1);
|
|
291
|
+
return s;
|
|
292
|
+
}
|
|
293
|
+
function formatValidationError(data) {
|
|
294
|
+
if (!data || typeof data !== "object") {
|
|
295
|
+
return `Validation error: ${JSON.stringify(data)}`;
|
|
296
|
+
}
|
|
297
|
+
if (Array.isArray(data)) {
|
|
298
|
+
const issues = data;
|
|
299
|
+
const parts = issues.slice(0, 5).map((issue) => {
|
|
300
|
+
const path = Array.isArray(issue.path) ? issue.path.join(".") : "";
|
|
301
|
+
const msg = issue.message || `expected ${issue.expected}` || issue.code || "invalid";
|
|
302
|
+
return path ? `${path}: ${msg}` : msg;
|
|
303
|
+
});
|
|
304
|
+
if (parts.length > 0) {
|
|
305
|
+
return `Validation failed \u2014 ${parts.join("; ")}. Fix the listed fields and retry.`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const obj = data;
|
|
309
|
+
if (obj.fieldErrors && typeof obj.fieldErrors === "object") {
|
|
310
|
+
const fieldErrors = obj.fieldErrors;
|
|
311
|
+
const parts = [];
|
|
312
|
+
for (const [field, messages] of Object.entries(fieldErrors)) {
|
|
313
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
314
|
+
parts.push(`${field}: ${messages[0]}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const formErrors = obj.formErrors;
|
|
318
|
+
if (Array.isArray(formErrors) && formErrors.length > 0) {
|
|
319
|
+
parts.push(formErrors[0]);
|
|
320
|
+
}
|
|
321
|
+
if (parts.length > 0) {
|
|
322
|
+
return `Validation failed \u2014 ${parts.join("; ")}. Fix the listed fields and retry.`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (obj.issues && Array.isArray(obj.issues)) {
|
|
326
|
+
const issues = obj.issues;
|
|
327
|
+
const parts = issues.slice(0, 5).map((issue) => {
|
|
328
|
+
const path = Array.isArray(issue.path) ? issue.path.join(".") : "";
|
|
329
|
+
const msg = issue.message || issue.code || "invalid";
|
|
330
|
+
return path ? `${path}: ${msg}` : msg;
|
|
331
|
+
});
|
|
332
|
+
return `Validation failed \u2014 ${parts.join("; ")}. Fix the listed fields and retry.`;
|
|
333
|
+
}
|
|
334
|
+
if (obj.error && typeof obj.error === "string") {
|
|
335
|
+
const details = obj.details;
|
|
336
|
+
if (details && typeof details === "object") {
|
|
337
|
+
return formatValidationError(details);
|
|
338
|
+
}
|
|
339
|
+
return obj.error;
|
|
340
|
+
}
|
|
341
|
+
if (obj.message && typeof obj.message === "string") {
|
|
342
|
+
return obj.message;
|
|
343
|
+
}
|
|
344
|
+
const json = JSON.stringify(data);
|
|
345
|
+
if (json.length > 500) {
|
|
346
|
+
return `Validation error (truncated): ${json.slice(0, 500)}...`;
|
|
347
|
+
}
|
|
348
|
+
return `Validation error: ${json}`;
|
|
349
|
+
}
|
|
350
|
+
function buildEntitySchemas(graph) {
|
|
351
|
+
return graph.nodes.map((node) => {
|
|
352
|
+
const relationships = graph.edges.filter((edge) => edge.source === node.className).map((edge) => ({
|
|
353
|
+
relationship: edge.relationship,
|
|
354
|
+
target: edge.target,
|
|
355
|
+
property: edge.property,
|
|
356
|
+
nullable: edge.nullable
|
|
357
|
+
}));
|
|
358
|
+
return {
|
|
359
|
+
className: node.className,
|
|
360
|
+
tableName: node.tableName,
|
|
361
|
+
module: inferModuleFromEntity(node.className, node.tableName),
|
|
362
|
+
fields: node.properties,
|
|
363
|
+
relationships
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
function detectMutationInCode(code) {
|
|
368
|
+
const methods = [];
|
|
369
|
+
const pattern = /method:\s*['"](\w+)['"]/gi;
|
|
370
|
+
let match;
|
|
371
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
372
|
+
const method = match[1].toUpperCase();
|
|
373
|
+
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
|
374
|
+
methods.push(method);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { hasMutation: methods.length > 0, methods };
|
|
378
|
+
}
|
|
379
|
+
async function loadCodeModeTools() {
|
|
380
|
+
const commonTypes = await generateCommonTypes();
|
|
381
|
+
registerSearchTool();
|
|
382
|
+
registerExecuteTool(commonTypes);
|
|
383
|
+
return 2;
|
|
384
|
+
}
|
|
385
|
+
function registerSearchTool() {
|
|
386
|
+
registerMcpTool(
|
|
387
|
+
{
|
|
388
|
+
name: "search",
|
|
389
|
+
description: `Query the OpenAPI spec and entity schemas. READ-ONLY, no side effects.
|
|
390
|
+
Globals: spec.findEndpoints(keyword), spec.describeEndpoint(path, method), spec.describeEntity(keyword), spec.paths, spec.entitySchemas.
|
|
391
|
+
Use BEFORE execute to learn endpoint schemas for CREATE/UPDATE. Skip for common paths (companies, people, orders, quotes, products).`,
|
|
392
|
+
inputSchema: z.object({
|
|
393
|
+
code: z.string().describe(
|
|
394
|
+
'An async arrow function that queries spec, e.g. async () => spec.paths["/api/customers/companies"]'
|
|
395
|
+
)
|
|
396
|
+
}),
|
|
397
|
+
requiredFeatures: [],
|
|
398
|
+
handler: async (input, ctx) => {
|
|
399
|
+
const codePreview = input.code.slice(0, 120).replace(/\n/g, " ");
|
|
400
|
+
console.error(`[AI Usage] search: code="${codePreview}${input.code.length > 120 ? "..." : ""}"`);
|
|
401
|
+
if (ctx.sessionId) {
|
|
402
|
+
const cached = lookupSearchCache(ctx.sessionId, input.code);
|
|
403
|
+
if (cached) {
|
|
404
|
+
console.error(`[AI Usage] search: CACHE HIT (label="${cached.label}")`);
|
|
405
|
+
const memoryContext2 = buildMemoryContext(ctx.sessionId);
|
|
406
|
+
return {
|
|
407
|
+
success: true,
|
|
408
|
+
result: cached.result,
|
|
409
|
+
fromCache: true,
|
|
410
|
+
_memoryContext: memoryContext2
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const { count, exceeded } = incrementToolCallCount(ctx.sessionId);
|
|
414
|
+
if (exceeded) {
|
|
415
|
+
console.error(`[AI Usage] search: TOOL CALL LIMIT EXCEEDED (count=${count})`);
|
|
416
|
+
return {
|
|
417
|
+
success: false,
|
|
418
|
+
error: "Tool call limit exceeded. Summarize what you know and respond to the user."
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const spec = await getCodeModeSpec();
|
|
423
|
+
const sandbox = createSandbox({ spec });
|
|
424
|
+
const result = await sandbox.execute(input.code);
|
|
425
|
+
if (result.error) {
|
|
426
|
+
console.error(`[AI Usage] search: ERROR in ${result.durationMs}ms \u2014 ${result.error}`);
|
|
427
|
+
return {
|
|
428
|
+
success: false,
|
|
429
|
+
error: result.error,
|
|
430
|
+
logs: result.logs,
|
|
431
|
+
durationMs: result.durationMs
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const truncated = truncateResult(result.result);
|
|
435
|
+
console.error(`[AI Usage] search: OK in ${result.durationMs}ms \u2014 ${truncated.length} chars`);
|
|
436
|
+
if (ctx.sessionId) {
|
|
437
|
+
const label = buildSearchLabel(input.code);
|
|
438
|
+
storeSearchResult(ctx.sessionId, input.code, truncated, label);
|
|
439
|
+
}
|
|
440
|
+
const memoryContext = ctx.sessionId ? buildMemoryContext(ctx.sessionId) : void 0;
|
|
441
|
+
return {
|
|
442
|
+
success: true,
|
|
443
|
+
result: truncated,
|
|
444
|
+
logs: result.logs,
|
|
445
|
+
durationMs: result.durationMs,
|
|
446
|
+
_memoryContext: memoryContext
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
{ moduleId: "codemode" }
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
function registerExecuteTool(commonTypes) {
|
|
454
|
+
const typesBlock = commonTypes ? `
|
|
455
|
+
|
|
456
|
+
${commonTypes}` : "";
|
|
457
|
+
registerMcpTool(
|
|
458
|
+
{
|
|
459
|
+
name: "execute",
|
|
460
|
+
description: `Make API calls. Returns JSON.
|
|
461
|
+
Globals: api.request({ method, path, query?, body? }) \u2192 { success, statusCode, data }, context { tenantId, organizationId, userId }.
|
|
462
|
+
RULES: For FIND/LIST \u2192 GET only (1 call). For UPDATE \u2192 PUT to collection path with id in BODY. NEVER PUT/POST/DELETE unless user explicitly asked to change data. Before ANY write operation (POST/PUT/DELETE), you MUST use the AskUserQuestion tool to get explicit user confirmation. Do NOT just ask in text \u2014 use the tool so execution pauses until the user responds.${typesBlock}`,
|
|
463
|
+
inputSchema: z.object({
|
|
464
|
+
code: z.string().describe(
|
|
465
|
+
'Async arrow function. For reads: async () => api.request({ method: "GET", path: "/api/customers/companies" }). For updates: async () => api.request({ method: "PUT", path: "/api/customers/companies", body: { id: "<uuid>", name: "New Name" } }). id goes in BODY not URL.'
|
|
466
|
+
)
|
|
467
|
+
}),
|
|
468
|
+
requiredFeatures: [],
|
|
469
|
+
// ACL checked at API level
|
|
470
|
+
handler: async (input, ctx) => {
|
|
471
|
+
const codePreview = input.code.slice(0, 120).replace(/\n/g, " ");
|
|
472
|
+
console.error(`[AI Usage] execute: code="${codePreview}${input.code.length > 120 ? "..." : ""}" user=${ctx.userId || "unknown"}`);
|
|
473
|
+
if (ctx.sessionId) {
|
|
474
|
+
const { count, exceeded } = incrementToolCallCount(ctx.sessionId);
|
|
475
|
+
if (exceeded) {
|
|
476
|
+
console.error(`[AI Usage] execute: TOOL CALL LIMIT EXCEEDED (count=${count})`);
|
|
477
|
+
return {
|
|
478
|
+
success: false,
|
|
479
|
+
error: "Tool call limit exceeded. Summarize what you know and respond to the user."
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const mutationInfo = detectMutationInCode(input.code);
|
|
484
|
+
const maxApiCalls = mutationInfo.hasMutation ? 20 : 50;
|
|
485
|
+
if (mutationInfo.hasMutation) {
|
|
486
|
+
console.error(`[AI Usage] execute: MUTATION DETECTED (${mutationInfo.methods.join(",")}) \u2014 capping API calls to ${maxApiCalls}`);
|
|
487
|
+
}
|
|
488
|
+
let apiCallCount = 0;
|
|
489
|
+
const apiRequestFn = createApiRequestFn(ctx, () => {
|
|
490
|
+
apiCallCount++;
|
|
491
|
+
if (apiCallCount > maxApiCalls) {
|
|
492
|
+
throw new Error(`API call limit exceeded (max ${maxApiCalls})`);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
const context = {
|
|
496
|
+
tenantId: ctx.tenantId,
|
|
497
|
+
organizationId: ctx.organizationId,
|
|
498
|
+
userId: ctx.userId
|
|
499
|
+
};
|
|
500
|
+
const sandbox = createSandbox(
|
|
501
|
+
{ api: { request: apiRequestFn }, context },
|
|
502
|
+
{ maxApiCalls }
|
|
503
|
+
);
|
|
504
|
+
const result = await sandbox.execute(input.code);
|
|
505
|
+
if (result.error) {
|
|
506
|
+
console.error(`[AI Usage] execute: ERROR in ${result.durationMs}ms \u2014 apiCalls=${apiCallCount} \u2014 ${result.error}`);
|
|
507
|
+
return {
|
|
508
|
+
success: false,
|
|
509
|
+
error: result.error,
|
|
510
|
+
logs: result.logs,
|
|
511
|
+
durationMs: result.durationMs,
|
|
512
|
+
apiCallCount
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const truncated = truncateResult(result.result);
|
|
516
|
+
console.error(`[AI Usage] execute: OK in ${result.durationMs}ms \u2014 apiCalls=${apiCallCount} \u2014 ${truncated.length} chars`);
|
|
517
|
+
const memoryContext = ctx.sessionId ? buildMemoryContext(ctx.sessionId) : void 0;
|
|
518
|
+
return {
|
|
519
|
+
success: true,
|
|
520
|
+
result: truncated,
|
|
521
|
+
logs: result.logs,
|
|
522
|
+
durationMs: result.durationMs,
|
|
523
|
+
apiCallCount,
|
|
524
|
+
_memoryContext: memoryContext
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
{ moduleId: "codemode" }
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
function createApiRequestFn(ctx, onCall) {
|
|
532
|
+
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || "http://localhost:3000";
|
|
533
|
+
return async (params) => {
|
|
534
|
+
onCall();
|
|
535
|
+
const { method, path, query, body } = params;
|
|
536
|
+
const callStart = Date.now();
|
|
537
|
+
const apiPath = path.startsWith("/api") ? path : `/api${path}`;
|
|
538
|
+
let url = `${baseUrl}${apiPath}`;
|
|
539
|
+
const queryParams = { ...query };
|
|
540
|
+
if (method === "GET") {
|
|
541
|
+
if (ctx.tenantId) queryParams.tenantId = ctx.tenantId;
|
|
542
|
+
if (ctx.organizationId) queryParams.organizationId = ctx.organizationId;
|
|
543
|
+
}
|
|
544
|
+
if (Object.keys(queryParams).length > 0) {
|
|
545
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
546
|
+
url += separator + new URLSearchParams(queryParams).toString();
|
|
547
|
+
}
|
|
548
|
+
let requestBody;
|
|
549
|
+
if (["POST", "PUT", "PATCH"].includes(method.toUpperCase())) {
|
|
550
|
+
requestBody = { ...body };
|
|
551
|
+
if (ctx.tenantId) requestBody.tenantId = ctx.tenantId;
|
|
552
|
+
if (ctx.organizationId) requestBody.organizationId = ctx.organizationId;
|
|
553
|
+
}
|
|
554
|
+
const headers = {
|
|
555
|
+
"Content-Type": "application/json"
|
|
556
|
+
};
|
|
557
|
+
if (ctx.apiKeySecret) headers["X-API-Key"] = ctx.apiKeySecret;
|
|
558
|
+
if (ctx.tenantId) headers["X-Tenant-Id"] = ctx.tenantId;
|
|
559
|
+
if (ctx.organizationId) headers["X-Organization-Id"] = ctx.organizationId;
|
|
560
|
+
const response = await globalThis.fetch(url, {
|
|
561
|
+
method: method.toUpperCase(),
|
|
562
|
+
headers,
|
|
563
|
+
body: requestBody ? JSON.stringify(requestBody) : void 0
|
|
564
|
+
});
|
|
565
|
+
const responseText = await response.text();
|
|
566
|
+
const data = tryParseJson(responseText);
|
|
567
|
+
const callDuration = Date.now() - callStart;
|
|
568
|
+
if (!response.ok) {
|
|
569
|
+
console.error(`[AI Usage] api.request: ${method.toUpperCase()} ${apiPath} \u2192 ${response.status} in ${callDuration}ms`);
|
|
570
|
+
if (response.status === 400) {
|
|
571
|
+
return {
|
|
572
|
+
success: false,
|
|
573
|
+
statusCode: 400,
|
|
574
|
+
error: formatValidationError(data)
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
success: false,
|
|
579
|
+
statusCode: response.status,
|
|
580
|
+
error: `API error ${response.status}`,
|
|
581
|
+
details: data
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
console.error(`[AI Usage] api.request: ${method.toUpperCase()} ${apiPath} \u2192 ${response.status} in ${callDuration}ms (${responseText.length} bytes)`);
|
|
585
|
+
if (!["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())) {
|
|
586
|
+
return {
|
|
587
|
+
success: true,
|
|
588
|
+
statusCode: response.status,
|
|
589
|
+
data,
|
|
590
|
+
_note: "WRITE operation performed. Only do writes when user explicitly requested data modification."
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
success: true,
|
|
595
|
+
statusCode: response.status,
|
|
596
|
+
data
|
|
597
|
+
};
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
function tryParseJson(text) {
|
|
601
|
+
try {
|
|
602
|
+
return JSON.parse(text);
|
|
603
|
+
} catch {
|
|
604
|
+
return text;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
export {
|
|
608
|
+
loadCodeModeTools
|
|
609
|
+
};
|
|
610
|
+
//# sourceMappingURL=codemode-tools.js.map
|