@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
|
@@ -12,6 +12,7 @@ import { jsonSchemaToZod, toSafeZodSchema } from './schema-utils'
|
|
|
12
12
|
import type { McpServerConfig, McpToolContext } from './types'
|
|
13
13
|
import type { SearchService } from '@open-mercato/search/service'
|
|
14
14
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
15
|
+
import type { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
|
|
15
16
|
import { findApiKeyBySecret, findSessionApiKeyWithSecret } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -90,6 +91,56 @@ async function resolveSessionContext(
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Resolve user context from the server-level API key (header-based auth fallback).
|
|
96
|
+
* Used when no session token is provided — loads the API key's ACL for RBAC.
|
|
97
|
+
*/
|
|
98
|
+
async function resolveApiKeyContext(
|
|
99
|
+
apiKeyRecord: ApiKey,
|
|
100
|
+
baseContext: McpToolContext,
|
|
101
|
+
debug?: boolean
|
|
102
|
+
): Promise<McpToolContext | null> {
|
|
103
|
+
try {
|
|
104
|
+
const rbacService = baseContext.container.resolve<RbacService>('rbacService')
|
|
105
|
+
const userId = apiKeyRecord.sessionUserId ?? apiKeyRecord.createdBy
|
|
106
|
+
if (!userId) {
|
|
107
|
+
if (debug) {
|
|
108
|
+
console.error(`[MCP HTTP] API key has no associated user`)
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const acl = await rbacService.loadAcl(`api_key:${apiKeyRecord.id}`, {
|
|
114
|
+
tenantId: apiKeyRecord.tenantId ?? null,
|
|
115
|
+
organizationId: apiKeyRecord.organizationId ?? null,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
if (debug) {
|
|
119
|
+
console.error(`[MCP HTTP] API key context resolved for user ${userId}:`, {
|
|
120
|
+
tenantId: apiKeyRecord.tenantId,
|
|
121
|
+
organizationId: apiKeyRecord.organizationId,
|
|
122
|
+
features: acl.features.length,
|
|
123
|
+
isSuperAdmin: acl.isSuperAdmin,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
tenantId: apiKeyRecord.tenantId ?? null,
|
|
129
|
+
organizationId: apiKeyRecord.organizationId ?? null,
|
|
130
|
+
userId,
|
|
131
|
+
container: baseContext.container,
|
|
132
|
+
userFeatures: acl.features,
|
|
133
|
+
isSuperAdmin: acl.isSuperAdmin,
|
|
134
|
+
apiKeySecret: baseContext.apiKeySecret,
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (debug) {
|
|
138
|
+
console.error(`[MCP HTTP] Error resolving API key context:`, error)
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
93
144
|
/**
|
|
94
145
|
* Create a stateless MCP server instance for a single request.
|
|
95
146
|
* Tools are registered without pre-filtering - permission checks happen at execution time
|
|
@@ -97,7 +148,8 @@ async function resolveSessionContext(
|
|
|
97
148
|
*/
|
|
98
149
|
function createMcpServerForRequest(
|
|
99
150
|
config: McpServerConfig,
|
|
100
|
-
toolContext: McpToolContext
|
|
151
|
+
toolContext: McpToolContext,
|
|
152
|
+
apiKeyRecord: ApiKey
|
|
101
153
|
): McpServer {
|
|
102
154
|
const server = new McpServer(
|
|
103
155
|
{ name: config.name, version: config.version },
|
|
@@ -130,7 +182,7 @@ function createMcpServerForRequest(
|
|
|
130
182
|
const properties = (jsonSchema.properties ?? {}) as Record<string, unknown>
|
|
131
183
|
properties._sessionToken = {
|
|
132
184
|
type: 'string',
|
|
133
|
-
description: 'Session authorization token
|
|
185
|
+
description: 'Session authorization token. If omitted, the server API key roles are used instead.',
|
|
134
186
|
}
|
|
135
187
|
jsonSchema.properties = properties
|
|
136
188
|
|
|
@@ -183,8 +235,8 @@ function createMcpServerForRequest(
|
|
|
183
235
|
if (sessionToken) {
|
|
184
236
|
const sessionContext = await resolveSessionContext(sessionToken, toolContext, config.debug)
|
|
185
237
|
if (sessionContext) {
|
|
186
|
-
// Session context includes the decrypted API key secret
|
|
187
|
-
effectiveContext = sessionContext
|
|
238
|
+
// Session context includes the decrypted API key secret + session ID for memory layer
|
|
239
|
+
effectiveContext = { ...sessionContext, sessionId: sessionToken }
|
|
188
240
|
} else {
|
|
189
241
|
// Session token expired - return user-friendly error for AI to relay
|
|
190
242
|
return {
|
|
@@ -201,14 +253,17 @@ function createMcpServerForRequest(
|
|
|
201
253
|
}
|
|
202
254
|
}
|
|
203
255
|
} else {
|
|
204
|
-
// No session token
|
|
205
|
-
|
|
256
|
+
// No session token — fall back to header API key auth
|
|
257
|
+
const apiKeyContext = await resolveApiKeyContext(apiKeyRecord, toolContext, config.debug)
|
|
258
|
+
if (apiKeyContext) {
|
|
259
|
+
effectiveContext = apiKeyContext
|
|
260
|
+
} else if (!effectiveContext.userId && effectiveContext.userFeatures.length === 0) {
|
|
206
261
|
return {
|
|
207
262
|
content: [
|
|
208
263
|
{
|
|
209
264
|
type: 'text' as const,
|
|
210
265
|
text: JSON.stringify({
|
|
211
|
-
error: '
|
|
266
|
+
error: 'Authentication failed: provide a session token (_sessionToken) or a valid API key with assigned roles',
|
|
212
267
|
code: 'UNAUTHORIZED',
|
|
213
268
|
}),
|
|
214
269
|
},
|
|
@@ -216,6 +271,15 @@ function createMcpServerForRequest(
|
|
|
216
271
|
isError: true,
|
|
217
272
|
}
|
|
218
273
|
}
|
|
274
|
+
|
|
275
|
+
// Derive a fallback sessionId from the API key so all tool calls
|
|
276
|
+
// within the same MCP connection share a session memory cache
|
|
277
|
+
if (!effectiveContext.sessionId && effectiveContext.apiKeySecret) {
|
|
278
|
+
effectiveContext = {
|
|
279
|
+
...effectiveContext,
|
|
280
|
+
sessionId: 'apikey_' + effectiveContext.apiKeySecret.slice(0, 16),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
219
283
|
}
|
|
220
284
|
|
|
221
285
|
// Check if user has required permissions for this tool
|
|
@@ -358,6 +422,19 @@ export async function runMcpHttpServer(options: McpHttpServerOptions): Promise<v
|
|
|
358
422
|
console.error('[MCP HTTP] Entity graph generation skipped:', error instanceof Error ? error.message : error)
|
|
359
423
|
}
|
|
360
424
|
|
|
425
|
+
// Pre-cache rich OpenAPI spec for Code Mode search tool (prefers runtime module registry over static JSON)
|
|
426
|
+
try {
|
|
427
|
+
const { loadRichOpenApiSpec } = await import('./api-endpoint-index')
|
|
428
|
+
const spec = await loadRichOpenApiSpec()
|
|
429
|
+
if (spec) {
|
|
430
|
+
console.error('[MCP HTTP] Rich OpenAPI spec cached for Code Mode (with requestBody schemas)')
|
|
431
|
+
} else {
|
|
432
|
+
console.error('[MCP HTTP] OpenAPI spec not available')
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
console.error('[MCP HTTP] OpenAPI spec caching skipped:', error instanceof Error ? error.message : error)
|
|
436
|
+
}
|
|
437
|
+
|
|
361
438
|
// Index tools, API endpoints, and entity schemas for hybrid search discovery (if search service available)
|
|
362
439
|
try {
|
|
363
440
|
const searchService = container.resolve('searchService') as SearchService
|
|
@@ -460,7 +537,7 @@ export async function runMcpHttpServer(options: McpHttpServerOptions): Promise<v
|
|
|
460
537
|
})
|
|
461
538
|
|
|
462
539
|
// Create new server for this request
|
|
463
|
-
const mcpServer = createMcpServerForRequest(config, toolContext)
|
|
540
|
+
const mcpServer = createMcpServerForRequest(config, toolContext, apiKeyRecord)
|
|
464
541
|
|
|
465
542
|
if (config.debug) {
|
|
466
543
|
// Check registered tools on the server
|
|
@@ -521,7 +598,7 @@ export async function runMcpHttpServer(options: McpHttpServerOptions): Promise<v
|
|
|
521
598
|
console.error(`[MCP HTTP] Tools registered: ${toolCount}`)
|
|
522
599
|
console.error(`[MCP HTTP] Mode: Stateless (new server per request)`)
|
|
523
600
|
console.error(`[MCP HTTP] Server Auth: API key validated against database (x-api-key header)`)
|
|
524
|
-
console.error(`[MCP HTTP] User Auth: Session token
|
|
601
|
+
console.error(`[MCP HTTP] User Auth: Session token (_sessionToken) preferred, falls back to API key roles`)
|
|
525
602
|
|
|
526
603
|
// Return a Promise that keeps the process alive until shutdown
|
|
527
604
|
return new Promise<void>((resolve) => {
|
|
@@ -265,6 +265,19 @@ export async function runMcpDevServer(): Promise<void> {
|
|
|
265
265
|
log('Entity graph generation skipped:', error instanceof Error ? error.message : error)
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// Pre-cache rich OpenAPI spec for Code Mode search tool (prefers runtime module registry over static JSON)
|
|
269
|
+
try {
|
|
270
|
+
const { loadRichOpenApiSpec } = await import('./api-endpoint-index')
|
|
271
|
+
const spec = await loadRichOpenApiSpec()
|
|
272
|
+
if (spec) {
|
|
273
|
+
log('Rich OpenAPI spec cached for Code Mode (with requestBody schemas)')
|
|
274
|
+
} else {
|
|
275
|
+
log('OpenAPI spec not available')
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
log('OpenAPI spec caching skipped:', error instanceof Error ? error.message : error)
|
|
279
|
+
}
|
|
280
|
+
|
|
268
281
|
// Index tools, API endpoints, and entity schemas for search (if search service available)
|
|
269
282
|
try {
|
|
270
283
|
const searchService = container.resolve('searchService') as SearchService
|
|
@@ -294,6 +307,11 @@ export async function runMcpDevServer(): Promise<void> {
|
|
|
294
307
|
log('Search indexing skipped (search service not available)')
|
|
295
308
|
}
|
|
296
309
|
|
|
310
|
+
// Generate a stable session ID for dev mode (enables session memory / caching)
|
|
311
|
+
const { randomBytes } = await import('node:crypto')
|
|
312
|
+
const devSessionId = 'dev_' + randomBytes(8).toString('hex')
|
|
313
|
+
log(`Session ID: ${devSessionId} (stable for this server instance)`)
|
|
314
|
+
|
|
297
315
|
// Create tool context from auth result
|
|
298
316
|
const toolContext: McpToolContext = {
|
|
299
317
|
tenantId: authResult.tenantId,
|
|
@@ -303,6 +321,7 @@ export async function runMcpDevServer(): Promise<void> {
|
|
|
303
321
|
userFeatures: authResult.features,
|
|
304
322
|
isSuperAdmin: authResult.isSuperAdmin,
|
|
305
323
|
apiKeySecret: apiKey,
|
|
324
|
+
sessionId: devSessionId,
|
|
306
325
|
}
|
|
307
326
|
|
|
308
327
|
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
@@ -160,6 +160,27 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
160
160
|
* 2. Manual context: Provide `context` with tenant/org/user
|
|
161
161
|
*/
|
|
162
162
|
export async function runMcpServer(options: McpServerOptions): Promise<void> {
|
|
163
|
+
// Generate entity graph for Code Mode search tool
|
|
164
|
+
try {
|
|
165
|
+
const { extractEntityGraph, cacheEntityGraph } = await import('./entity-graph')
|
|
166
|
+
const { getOrm } = await import('@open-mercato/shared/lib/db/mikro')
|
|
167
|
+
const orm = await getOrm()
|
|
168
|
+
const graph = await extractEntityGraph(orm)
|
|
169
|
+
cacheEntityGraph(graph)
|
|
170
|
+
console.error(`[MCP Server] Entity graph: ${graph.nodes.length} entities`)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error('[MCP Server] Entity graph skipped:', error instanceof Error ? error.message : error)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Pre-cache raw OpenAPI spec for Code Mode search tool
|
|
176
|
+
try {
|
|
177
|
+
const { getRawOpenApiSpec } = await import('./api-endpoint-index')
|
|
178
|
+
await getRawOpenApiSpec()
|
|
179
|
+
console.error('[MCP Server] Raw OpenAPI spec cached for Code Mode')
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error('[MCP Server] Raw OpenAPI spec caching skipped:', error instanceof Error ? error.message : error)
|
|
182
|
+
}
|
|
183
|
+
|
|
163
184
|
// Load tools from all modules before starting
|
|
164
185
|
await loadAllModuleTools()
|
|
165
186
|
|
|
@@ -251,6 +251,15 @@ export async function handleOpenCodeMessageStreaming(
|
|
|
251
251
|
const { message, sessionId, model } = request
|
|
252
252
|
const startTime = Date.now()
|
|
253
253
|
|
|
254
|
+
// Accumulators for usage summary
|
|
255
|
+
const usageStats = {
|
|
256
|
+
toolCalls: 0,
|
|
257
|
+
toolNames: [] as string[],
|
|
258
|
+
totalInputTokens: 0,
|
|
259
|
+
totalOutputTokens: 0,
|
|
260
|
+
messageCount: 0,
|
|
261
|
+
}
|
|
262
|
+
|
|
254
263
|
if (!message) {
|
|
255
264
|
await onEvent({ type: 'error', error: 'Message is required' })
|
|
256
265
|
return
|
|
@@ -332,6 +341,8 @@ export async function handleOpenCodeMessageStreaming(
|
|
|
332
341
|
} else if (status.status === 'idle') {
|
|
333
342
|
// Session is explicitly idle and no questions - complete
|
|
334
343
|
resolved = true
|
|
344
|
+
const durationMs = Date.now() - startTime
|
|
345
|
+
console.error(`[AI Usage] Session complete (heartbeat): sessionId=${targetSessionId.slice(0, 16)}... duration=${durationMs}ms tokens={in:${usageStats.totalInputTokens},out:${usageStats.totalOutputTokens}} toolCalls=${usageStats.toolCalls} tools=[${usageStats.toolNames.join(',')}] messages=${usageStats.messageCount}`)
|
|
335
346
|
try {
|
|
336
347
|
await onEvent({ type: 'done', sessionId: targetSessionId })
|
|
337
348
|
} catch (err) {
|
|
@@ -457,6 +468,8 @@ export async function handleOpenCodeMessageStreaming(
|
|
|
457
468
|
} else {
|
|
458
469
|
// Truly idle - complete the stream
|
|
459
470
|
resolved = true
|
|
471
|
+
const durationMs = Date.now() - startTime
|
|
472
|
+
console.error(`[AI Usage] Session complete: sessionId=${targetSessionId.slice(0, 16)}... duration=${durationMs}ms tokens={in:${usageStats.totalInputTokens},out:${usageStats.totalOutputTokens}} toolCalls=${usageStats.toolCalls} tools=[${usageStats.toolNames.join(',')}] messages=${usageStats.messageCount}`)
|
|
460
473
|
await onEvent({ type: 'done', sessionId: targetSessionId })
|
|
461
474
|
cleanup()
|
|
462
475
|
clearTimeout(timeout)
|
|
@@ -517,6 +530,15 @@ export async function handleOpenCodeMessageStreaming(
|
|
|
517
530
|
provider: info.providerID,
|
|
518
531
|
tokens: info.tokens,
|
|
519
532
|
}
|
|
533
|
+
|
|
534
|
+
// Accumulate token usage
|
|
535
|
+
usageStats.messageCount++
|
|
536
|
+
if (info.tokens) {
|
|
537
|
+
usageStats.totalInputTokens += info.tokens.input || 0
|
|
538
|
+
usageStats.totalOutputTokens += info.tokens.output || 0
|
|
539
|
+
console.error(`[AI Usage] Tokens (message ${usageStats.messageCount}): input=${info.tokens.input} output=${info.tokens.output} model=${info.modelID || 'unknown'}`)
|
|
540
|
+
}
|
|
541
|
+
|
|
520
542
|
// Emit intermediate metadata for visibility
|
|
521
543
|
await onEvent({
|
|
522
544
|
type: 'debug',
|
|
@@ -548,8 +570,16 @@ export async function handleOpenCodeMessageStreaming(
|
|
|
548
570
|
await onEvent({ type: 'text', content: delta })
|
|
549
571
|
}
|
|
550
572
|
break
|
|
573
|
+
case 'thinking':
|
|
574
|
+
// Extended thinking blocks — route to debug panel only, never to chat
|
|
575
|
+
console.error(`[OpenCode SSE] Thinking block received (${(delta || part.text || '').length} chars)`)
|
|
576
|
+
await onEvent({ type: 'debug', partType: 'thinking', data: { text: delta || part.text } })
|
|
577
|
+
break
|
|
551
578
|
case 'tool_use':
|
|
552
579
|
if (part.name) {
|
|
580
|
+
usageStats.toolCalls++
|
|
581
|
+
usageStats.toolNames.push(part.name)
|
|
582
|
+
console.error(`[AI Usage] Tool call #${usageStats.toolCalls}: ${part.name}`)
|
|
553
583
|
await onEvent({
|
|
554
584
|
type: 'tool-call',
|
|
555
585
|
id: part.id,
|
|
@@ -572,6 +602,16 @@ export async function handleOpenCodeMessageStreaming(
|
|
|
572
602
|
}
|
|
573
603
|
break
|
|
574
604
|
}
|
|
605
|
+
|
|
606
|
+
case 'message.part.delta': {
|
|
607
|
+
const part = properties.part as { type?: string } | undefined
|
|
608
|
+
const delta = properties.delta as string | undefined
|
|
609
|
+
// Filter out thinking deltas — only stream text part deltas to chat
|
|
610
|
+
if (delta && part?.type !== 'thinking') {
|
|
611
|
+
await onEvent({ type: 'text', content: delta })
|
|
612
|
+
}
|
|
613
|
+
break
|
|
614
|
+
}
|
|
575
615
|
}
|
|
576
616
|
} catch (err) {
|
|
577
617
|
console.error('[OpenCode SSE] Error processing event:', err)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed Code Execution Engine
|
|
3
|
+
*
|
|
4
|
+
* Uses node:vm to run AI-generated JavaScript in a restricted sandbox.
|
|
5
|
+
* Only whitelisted globals are available — no file system, network, or process access.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import vm from 'node:vm'
|
|
9
|
+
|
|
10
|
+
export interface SandboxOptions {
|
|
11
|
+
/** Execution timeout in milliseconds (default: 30_000) */
|
|
12
|
+
timeout?: number
|
|
13
|
+
/** Maximum output size in bytes (default: 1_048_576 / 1MB) */
|
|
14
|
+
maxOutputSize?: number
|
|
15
|
+
/** Maximum number of api.request() calls allowed (default: 50) */
|
|
16
|
+
maxApiCalls?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SandboxResult {
|
|
20
|
+
result: unknown
|
|
21
|
+
error?: string
|
|
22
|
+
logs: string[]
|
|
23
|
+
durationMs: number
|
|
24
|
+
apiCallCount?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_LOG_ENTRIES = 100
|
|
28
|
+
const MAX_LOG_ENTRY_LENGTH = 1000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a sandboxed execution environment.
|
|
32
|
+
*
|
|
33
|
+
* @param globals - Custom globals to inject (e.g., spec, api, context)
|
|
34
|
+
* @param options - Sandbox configuration
|
|
35
|
+
*/
|
|
36
|
+
export function createSandbox(
|
|
37
|
+
globals: Record<string, unknown>,
|
|
38
|
+
options: SandboxOptions = {}
|
|
39
|
+
) {
|
|
40
|
+
const { timeout = 30_000, maxApiCalls = 50 } = options
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
async execute(code: string): Promise<SandboxResult> {
|
|
44
|
+
const logs: string[] = []
|
|
45
|
+
const start = Date.now()
|
|
46
|
+
|
|
47
|
+
// Capture console output
|
|
48
|
+
const consolProxy = {
|
|
49
|
+
log: (...args: unknown[]) => pushLog(logs, args),
|
|
50
|
+
info: (...args: unknown[]) => pushLog(logs, args),
|
|
51
|
+
warn: (...args: unknown[]) => pushLog(logs, args),
|
|
52
|
+
error: (...args: unknown[]) => pushLog(logs, args),
|
|
53
|
+
debug: (...args: unknown[]) => pushLog(logs, args),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Build context with safe globals + caller-provided globals
|
|
57
|
+
const contextGlobals: Record<string, unknown> = {
|
|
58
|
+
// Safe built-ins
|
|
59
|
+
JSON,
|
|
60
|
+
Object,
|
|
61
|
+
Array,
|
|
62
|
+
Map,
|
|
63
|
+
Set,
|
|
64
|
+
Promise,
|
|
65
|
+
Math,
|
|
66
|
+
Date,
|
|
67
|
+
RegExp,
|
|
68
|
+
String,
|
|
69
|
+
Number,
|
|
70
|
+
Boolean,
|
|
71
|
+
parseInt,
|
|
72
|
+
parseFloat,
|
|
73
|
+
isNaN,
|
|
74
|
+
isFinite,
|
|
75
|
+
encodeURIComponent,
|
|
76
|
+
decodeURIComponent,
|
|
77
|
+
Error,
|
|
78
|
+
TypeError,
|
|
79
|
+
RangeError,
|
|
80
|
+
undefined,
|
|
81
|
+
NaN,
|
|
82
|
+
Infinity,
|
|
83
|
+
|
|
84
|
+
// Sandboxed console
|
|
85
|
+
console: consolProxy,
|
|
86
|
+
|
|
87
|
+
// Blocked — explicitly set to undefined
|
|
88
|
+
require: undefined,
|
|
89
|
+
process: undefined,
|
|
90
|
+
global: undefined,
|
|
91
|
+
globalThis: undefined,
|
|
92
|
+
fetch: undefined,
|
|
93
|
+
XMLHttpRequest: undefined,
|
|
94
|
+
WebSocket: undefined,
|
|
95
|
+
Buffer: undefined,
|
|
96
|
+
setTimeout: undefined,
|
|
97
|
+
setInterval: undefined,
|
|
98
|
+
__dirname: undefined,
|
|
99
|
+
__filename: undefined,
|
|
100
|
+
|
|
101
|
+
// Caller-provided globals (spec, api, context, etc.)
|
|
102
|
+
...globals,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ctx = vm.createContext(contextGlobals)
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const normalized = normalizeCode(code)
|
|
109
|
+
|
|
110
|
+
// Invoke the normalized async function directly
|
|
111
|
+
const wrapped = `(${normalized})()`
|
|
112
|
+
|
|
113
|
+
const script = new vm.Script(wrapped, {
|
|
114
|
+
filename: 'sandbox.js',
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Run the script — returns a Promise
|
|
118
|
+
const promise = script.runInContext(ctx, { timeout })
|
|
119
|
+
|
|
120
|
+
// Await with secondary timeout (for async operations like api.request)
|
|
121
|
+
const result = await Promise.race([
|
|
122
|
+
promise,
|
|
123
|
+
new Promise((_, reject) =>
|
|
124
|
+
// Use global setTimeout (not the blocked sandbox one)
|
|
125
|
+
globalThis.setTimeout(
|
|
126
|
+
() => reject(new Error(`Execution timed out after ${timeout}ms`)),
|
|
127
|
+
timeout
|
|
128
|
+
)
|
|
129
|
+
),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
result,
|
|
134
|
+
logs,
|
|
135
|
+
durationMs: Date.now() - start,
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
result: null,
|
|
140
|
+
error: error instanceof Error ? error.message : String(error),
|
|
141
|
+
logs,
|
|
142
|
+
durationMs: Date.now() - start,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Normalize AI-generated code: strip markdown fencing and validate shape.
|
|
151
|
+
*/
|
|
152
|
+
export function normalizeCode(code: string): string {
|
|
153
|
+
let normalized = code.trim()
|
|
154
|
+
|
|
155
|
+
// Strip markdown code fences
|
|
156
|
+
normalized = normalized
|
|
157
|
+
.replace(/^```(?:javascript|js|typescript|ts)?\s*\n?/i, '')
|
|
158
|
+
.replace(/\n?```\s*$/, '')
|
|
159
|
+
.trim()
|
|
160
|
+
|
|
161
|
+
// Auto-wrap bare code into async arrow functions
|
|
162
|
+
if (!/^\s*async\s*\(/.test(normalized)) {
|
|
163
|
+
// Detect statement-leading keywords — these cannot follow `return`
|
|
164
|
+
const isStatement = /^\s*(const|let|var|for|while|if|try|switch|return|throw|class|function)\b/.test(normalized)
|
|
165
|
+
normalized = isStatement
|
|
166
|
+
? `async () => { ${normalized} }`
|
|
167
|
+
: `async () => { return ${normalized} }`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return normalized
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function pushLog(logs: string[], args: unknown[]): void {
|
|
174
|
+
if (logs.length >= MAX_LOG_ENTRIES) return
|
|
175
|
+
|
|
176
|
+
const message = args
|
|
177
|
+
.map((arg) => {
|
|
178
|
+
if (typeof arg === 'string') return arg
|
|
179
|
+
try {
|
|
180
|
+
return JSON.stringify(arg)
|
|
181
|
+
} catch {
|
|
182
|
+
return String(arg)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
.join(' ')
|
|
186
|
+
|
|
187
|
+
logs.push(
|
|
188
|
+
message.length > MAX_LOG_ENTRY_LENGTH
|
|
189
|
+
? message.slice(0, MAX_LOG_ENTRY_LENGTH) + '...'
|
|
190
|
+
: message
|
|
191
|
+
)
|
|
192
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Memory
|
|
3
|
+
*
|
|
4
|
+
* Process-level in-memory cache keyed by session token (`sess_xxx`).
|
|
5
|
+
* Deduplicates schema/spec search queries within a single conversation,
|
|
6
|
+
* reducing redundant tool calls by the AI agent.
|
|
7
|
+
*
|
|
8
|
+
* - Search cache: exact code string match → return cached result (max 50 entries)
|
|
9
|
+
* - Memory context summary appended to every response to remind the agent
|
|
10
|
+
* - Sessions auto-expire after 5 minutes (short-lived cache for active conversations)
|
|
11
|
+
*
|
|
12
|
+
* NOTE: Only schema/spec lookups are cached. API responses are NEVER cached
|
|
13
|
+
* because data can change from external sources between calls.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
const SESSION_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
19
|
+
const CLEANUP_INTERVAL_MS = 2 * 60 * 1000 // 2 minutes
|
|
20
|
+
const MAX_SEARCH_ENTRIES = 50
|
|
21
|
+
const MAX_MEMORY_CONTEXT_LENGTH = 500
|
|
22
|
+
|
|
23
|
+
interface CacheEntry {
|
|
24
|
+
result: unknown
|
|
25
|
+
timestamp: number
|
|
26
|
+
label: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TOOL_CALL_HARD_CAP = 10
|
|
30
|
+
const TOOL_CALL_WINDOW_MS = 60 * 1000 // 60 seconds — resets for new user message
|
|
31
|
+
|
|
32
|
+
interface SessionMemory {
|
|
33
|
+
searchCache: Map<string, CacheEntry>
|
|
34
|
+
createdAt: number
|
|
35
|
+
lastAccessedAt: number
|
|
36
|
+
toolCallCount: number
|
|
37
|
+
toolCallWindowStart: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sessions = new Map<string, SessionMemory>()
|
|
41
|
+
|
|
42
|
+
function getOrCreateSession(token: string): SessionMemory {
|
|
43
|
+
let session = sessions.get(token)
|
|
44
|
+
if (!session) {
|
|
45
|
+
session = {
|
|
46
|
+
searchCache: new Map(),
|
|
47
|
+
createdAt: Date.now(),
|
|
48
|
+
lastAccessedAt: Date.now(),
|
|
49
|
+
toolCallCount: 0,
|
|
50
|
+
toolCallWindowStart: Date.now(),
|
|
51
|
+
}
|
|
52
|
+
sessions.set(token, session)
|
|
53
|
+
}
|
|
54
|
+
session.lastAccessedAt = Date.now()
|
|
55
|
+
return session
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hashCode(code: string): string {
|
|
59
|
+
return createHash('sha256').update(code).digest('hex').slice(0, 16)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function evictOldest(map: Map<string, CacheEntry>, maxSize: number): void {
|
|
63
|
+
if (map.size <= maxSize) return
|
|
64
|
+
|
|
65
|
+
let oldestKey: string | null = null
|
|
66
|
+
let oldestTime = Infinity
|
|
67
|
+
|
|
68
|
+
for (const [key, entry] of map) {
|
|
69
|
+
if (entry.timestamp < oldestTime) {
|
|
70
|
+
oldestTime = entry.timestamp
|
|
71
|
+
oldestKey = key
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (oldestKey) {
|
|
76
|
+
map.delete(oldestKey)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Look up a cached search result by exact code string match.
|
|
82
|
+
*/
|
|
83
|
+
export function lookupSearchCache(token: string, code: string): CacheEntry | null {
|
|
84
|
+
const session = sessions.get(token)
|
|
85
|
+
if (!session) return null
|
|
86
|
+
session.lastAccessedAt = Date.now()
|
|
87
|
+
|
|
88
|
+
const key = hashCode(code)
|
|
89
|
+
return session.searchCache.get(key) ?? null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Store a search result in the session cache.
|
|
94
|
+
*/
|
|
95
|
+
export function storeSearchResult(token: string, code: string, result: unknown, label: string): void {
|
|
96
|
+
const session = getOrCreateSession(token)
|
|
97
|
+
const key = hashCode(code)
|
|
98
|
+
|
|
99
|
+
evictOldest(session.searchCache, MAX_SEARCH_ENTRIES)
|
|
100
|
+
session.searchCache.set(key, { result, timestamp: Date.now(), label })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Increment the tool call counter for a session.
|
|
105
|
+
* Resets the counter if more than TOOL_CALL_WINDOW_MS has elapsed (new user message).
|
|
106
|
+
* Returns the current count and whether the hard cap has been exceeded.
|
|
107
|
+
*/
|
|
108
|
+
export function incrementToolCallCount(token: string): { count: number; exceeded: boolean } {
|
|
109
|
+
const session = getOrCreateSession(token)
|
|
110
|
+
const now = Date.now()
|
|
111
|
+
|
|
112
|
+
// Reset counter if the window has expired (likely a new user message)
|
|
113
|
+
if (now - session.toolCallWindowStart > TOOL_CALL_WINDOW_MS) {
|
|
114
|
+
session.toolCallCount = 0
|
|
115
|
+
session.toolCallWindowStart = now
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
session.toolCallCount++
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
count: session.toolCallCount,
|
|
122
|
+
exceeded: session.toolCallCount > TOOL_CALL_HARD_CAP,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a summary string of what this session has already discovered.
|
|
128
|
+
* Appended to tool responses so the agent knows what's cached.
|
|
129
|
+
*/
|
|
130
|
+
export function buildMemoryContext(token: string): string {
|
|
131
|
+
const session = sessions.get(token)
|
|
132
|
+
if (!session) return ''
|
|
133
|
+
|
|
134
|
+
const searchCount = session.searchCache.size
|
|
135
|
+
if (searchCount === 0) return ''
|
|
136
|
+
|
|
137
|
+
const labels = Array.from(session.searchCache.values())
|
|
138
|
+
.slice(-3)
|
|
139
|
+
.map((e) => e.label)
|
|
140
|
+
.join(', ')
|
|
141
|
+
|
|
142
|
+
const context = `[Memory: ${searchCount} schema searches cached (${labels}). Reuse previous schema results instead of re-calling.]`
|
|
143
|
+
if (context.length > MAX_MEMORY_CONTEXT_LENGTH) {
|
|
144
|
+
return context.slice(0, MAX_MEMORY_CONTEXT_LENGTH - 3) + '...]'
|
|
145
|
+
}
|
|
146
|
+
return context
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build a short label from search code for the memory context summary.
|
|
151
|
+
*/
|
|
152
|
+
export function buildSearchLabel(code: string): string {
|
|
153
|
+
// Try to extract the helper call: spec.findEndpoints('companies')
|
|
154
|
+
const helperMatch = code.match(/spec\.(findEndpoints|describeEndpoint|describeEntity)\s*\(([^)]*)\)/)
|
|
155
|
+
if (helperMatch) {
|
|
156
|
+
return `${helperMatch[1]}(${helperMatch[2].slice(0, 30)})`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fallback: first meaningful portion
|
|
160
|
+
const cleaned = code.replace(/async\s*\(\)\s*=>\s*/, '').trim()
|
|
161
|
+
return cleaned.slice(0, 40)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Periodic cleanup of expired sessions
|
|
165
|
+
const cleanupTimer = setInterval(() => {
|
|
166
|
+
const now = Date.now()
|
|
167
|
+
for (const [token, session] of sessions) {
|
|
168
|
+
if (now - session.lastAccessedAt > SESSION_TTL_MS) {
|
|
169
|
+
sessions.delete(token)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}, CLEANUP_INTERVAL_MS)
|
|
173
|
+
|
|
174
|
+
cleanupTimer.unref()
|