@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.
Files changed (41) hide show
  1. package/AGENTS.md +78 -37
  2. package/dist/modules/ai_assistant/api/chat/route.js +104 -13
  3. package/dist/modules/ai_assistant/api/chat/route.js.map +2 -2
  4. package/dist/modules/ai_assistant/lib/api-endpoint-index.js +97 -0
  5. package/dist/modules/ai_assistant/lib/api-endpoint-index.js.map +2 -2
  6. package/dist/modules/ai_assistant/lib/codemode-tools.js +610 -0
  7. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +7 -0
  8. package/dist/modules/ai_assistant/lib/http-server.js +65 -7
  9. package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
  10. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +16 -1
  11. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
  12. package/dist/modules/ai_assistant/lib/mcp-server.js +17 -0
  13. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  14. package/dist/modules/ai_assistant/lib/opencode-handlers.js +32 -0
  15. package/dist/modules/ai_assistant/lib/opencode-handlers.js.map +2 -2
  16. package/dist/modules/ai_assistant/lib/sandbox.js +124 -0
  17. package/dist/modules/ai_assistant/lib/sandbox.js.map +7 -0
  18. package/dist/modules/ai_assistant/lib/session-memory.js +103 -0
  19. package/dist/modules/ai_assistant/lib/session-memory.js.map +7 -0
  20. package/dist/modules/ai_assistant/lib/tool-loader.js +4 -114
  21. package/dist/modules/ai_assistant/lib/tool-loader.js.map +3 -3
  22. package/dist/modules/ai_assistant/lib/truncate.js +26 -0
  23. package/dist/modules/ai_assistant/lib/truncate.js.map +7 -0
  24. package/jest.config.cjs +23 -0
  25. package/package.json +6 -5
  26. package/src/modules/ai_assistant/api/chat/route.ts +110 -14
  27. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +129 -0
  28. package/src/modules/ai_assistant/lib/__tests__/sandbox.test.ts +642 -0
  29. package/src/modules/ai_assistant/lib/__tests__/session-memory.test.ts +82 -0
  30. package/src/modules/ai_assistant/lib/__tests__/truncate.test.ts +76 -0
  31. package/src/modules/ai_assistant/lib/api-endpoint-index.ts +143 -0
  32. package/src/modules/ai_assistant/lib/codemode-tools.ts +864 -0
  33. package/src/modules/ai_assistant/lib/http-server.ts +86 -9
  34. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +19 -0
  35. package/src/modules/ai_assistant/lib/mcp-server.ts +21 -0
  36. package/src/modules/ai_assistant/lib/opencode-handlers.ts +40 -0
  37. package/src/modules/ai_assistant/lib/sandbox.ts +192 -0
  38. package/src/modules/ai_assistant/lib/session-memory.ts +174 -0
  39. package/src/modules/ai_assistant/lib/tool-loader.ts +11 -145
  40. package/src/modules/ai_assistant/lib/truncate.ts +45 -0
  41. package/src/modules/ai_assistant/lib/types.ts +2 -0
@@ -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 (REQUIRED for all tool calls)',
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 provided - reject if base context has no permissions
205
- if (!effectiveContext.userId && effectiveContext.userFeatures.length === 0) {
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: 'Session token required (_sessionToken parameter)',
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 in _sessionToken parameter`)
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()