@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
@@ -0,0 +1,82 @@
1
+ import {
2
+ incrementToolCallCount,
3
+ buildMemoryContext,
4
+ buildSearchLabel,
5
+ storeSearchResult,
6
+ } from '../session-memory'
7
+
8
+ describe('session-memory', () => {
9
+
10
+ describe('tool call counting', () => {
11
+ it('increments count per session', () => {
12
+ const countToken = `count_${Date.now()}`
13
+ const r1 = incrementToolCallCount(countToken)
14
+ expect(r1.count).toBe(1)
15
+ expect(r1.exceeded).toBe(false)
16
+
17
+ const r2 = incrementToolCallCount(countToken)
18
+ expect(r2.count).toBe(2)
19
+ expect(r2.exceeded).toBe(false)
20
+ })
21
+
22
+ it('enforces hard cap at 10 calls', () => {
23
+ const capToken = `cap_${Date.now()}`
24
+ for (let i = 0; i < 10; i++) {
25
+ const r = incrementToolCallCount(capToken)
26
+ expect(r.exceeded).toBe(false)
27
+ }
28
+ const r11 = incrementToolCallCount(capToken)
29
+ expect(r11.count).toBe(11)
30
+ expect(r11.exceeded).toBe(true)
31
+ })
32
+ })
33
+
34
+ describe('buildMemoryContext', () => {
35
+ it('returns empty for unknown session', () => {
36
+ expect(buildMemoryContext('nonexistent_session_ctx')).toBe('')
37
+ })
38
+
39
+ it('returns empty for session with no searches', () => {
40
+ const ctxToken = `ctx_empty_${Date.now()}`
41
+ incrementToolCallCount(ctxToken) // creates session
42
+ expect(buildMemoryContext(ctxToken)).toBe('')
43
+ })
44
+
45
+ it('includes search count and labels', () => {
46
+ const ctxToken = `ctx_labels_${Date.now()}`
47
+ storeSearchResult(ctxToken, 'code1', 'r1', 'companies lookup')
48
+ storeSearchResult(ctxToken, 'code2', 'r2', 'orders lookup')
49
+ const ctx = buildMemoryContext(ctxToken)
50
+ expect(ctx).toContain('2 schema searches cached')
51
+ expect(ctx).toContain('companies lookup')
52
+ expect(ctx).toContain('orders lookup')
53
+ })
54
+ })
55
+
56
+ describe('buildSearchLabel', () => {
57
+ it('extracts findEndpoints helper call', () => {
58
+ expect(buildSearchLabel('async () => spec.findEndpoints("customer")')).toBe(
59
+ 'findEndpoints("customer")'
60
+ )
61
+ })
62
+
63
+ it('extracts describeEndpoint helper call', () => {
64
+ expect(
65
+ buildSearchLabel(
66
+ 'async () => spec.describeEndpoint("/api/sales/orders", "post")'
67
+ )
68
+ ).toBe('describeEndpoint("/api/sales/orders", "post")')
69
+ })
70
+
71
+ it('extracts describeEntity helper call', () => {
72
+ expect(buildSearchLabel('async () => spec.describeEntity("company")')).toBe(
73
+ 'describeEntity("company")'
74
+ )
75
+ })
76
+
77
+ it('falls back to code prefix for non-helper calls', () => {
78
+ const label = buildSearchLabel('async () => Object.keys(spec.paths)')
79
+ expect(label).toBe('Object.keys(spec.paths)')
80
+ })
81
+ })
82
+ })
@@ -0,0 +1,76 @@
1
+ import { truncateResult } from '../truncate'
2
+
3
+ describe('truncateResult', () => {
4
+ it('serializes simple values', () => {
5
+ expect(truncateResult(42)).toBe('42')
6
+ expect(truncateResult('hello')).toBe('"hello"')
7
+ expect(truncateResult(true)).toBe('true')
8
+ expect(truncateResult(null)).toBe('null')
9
+ })
10
+
11
+ it('returns "undefined" for undefined', () => {
12
+ expect(truncateResult(undefined)).toBe('undefined')
13
+ })
14
+
15
+ it('serializes objects with pretty printing', () => {
16
+ const result = truncateResult({ a: 1, b: 2 })
17
+ expect(JSON.parse(result)).toEqual({ a: 1, b: 2 })
18
+ expect(result).toContain('\n') // pretty printed
19
+ })
20
+
21
+ it('serializes arrays', () => {
22
+ const result = truncateResult([1, 2, 3])
23
+ expect(JSON.parse(result)).toEqual([1, 2, 3])
24
+ })
25
+
26
+ it('truncates large output', () => {
27
+ const largeArray = Array.from({ length: 10000 }, (_, i) => ({
28
+ id: `item-${i}`,
29
+ name: `Item number ${i} with some extra text`,
30
+ }))
31
+ const result = truncateResult(largeArray)
32
+ expect(result.length).toBeLessThanOrEqual(40_100) // ~40K + truncation message
33
+ expect(result).toContain('truncated')
34
+ })
35
+
36
+ it('respects custom maxChars', () => {
37
+ const data = { message: 'x'.repeat(200) }
38
+ const result = truncateResult(data, 100)
39
+ expect(result.length).toBeLessThanOrEqual(200)
40
+ expect(result).toContain('truncated')
41
+ })
42
+
43
+ it('does not truncate small output', () => {
44
+ const data = { name: 'test', count: 42 }
45
+ const result = truncateResult(data)
46
+ expect(result).not.toContain('truncated')
47
+ expect(JSON.parse(result)).toEqual(data)
48
+ })
49
+
50
+ it('handles circular references', () => {
51
+ const obj: Record<string, unknown> = { name: 'test' }
52
+ obj.self = obj
53
+ const result = truncateResult(obj)
54
+ expect(result).toContain('[Circular]')
55
+ expect(result).toContain('test')
56
+ })
57
+
58
+ it('handles non-serializable values', () => {
59
+ const result = truncateResult(BigInt(42))
60
+ expect(typeof result).toBe('string')
61
+ })
62
+
63
+ it('handles nested objects', () => {
64
+ const data = {
65
+ company: {
66
+ name: 'ACME',
67
+ address: { city: 'New York', country: 'US' },
68
+ contacts: [{ name: 'John' }, { name: 'Jane' }],
69
+ },
70
+ }
71
+ const result = truncateResult(data)
72
+ const parsed = JSON.parse(result)
73
+ expect(parsed.company.address.city).toBe('New York')
74
+ expect(parsed.company.contacts).toHaveLength(2)
75
+ })
76
+ })
@@ -54,6 +54,11 @@ export const API_ENDPOINT_ENTITY = API_ENDPOINT_ENTITY_ID
54
54
  let endpointsCache: ApiEndpoint[] | null = null
55
55
  let endpointsByOperationId: Map<string, ApiEndpoint> | null = null
56
56
 
57
+ /**
58
+ * In-memory cache of the raw OpenAPI spec document (for Code Mode search tool)
59
+ */
60
+ let rawSpecCache: OpenApiDocument | null = null
61
+
57
62
  /**
58
63
  * Get all parsed API endpoints (cached)
59
64
  */
@@ -76,6 +81,143 @@ export async function getEndpointByOperationId(operationId: string): Promise<Api
76
81
  return endpointsByOperationId?.get(operationId) ?? null
77
82
  }
78
83
 
84
+ /**
85
+ * Get the raw OpenAPI spec document (cached).
86
+ * Uses the same 3-tier loading strategy as parseApiEndpoints():
87
+ * generated JSON → module registry → HTTP fetch.
88
+ */
89
+ export async function getRawOpenApiSpec(): Promise<OpenApiDocument | null> {
90
+ if (rawSpecCache) return rawSpecCache
91
+ rawSpecCache = await loadRawOpenApiSpec()
92
+ return rawSpecCache
93
+ }
94
+
95
+ /**
96
+ * Set the raw OpenAPI spec cache directly.
97
+ * Used by servers that want to inject a pre-built spec.
98
+ */
99
+ export function setRawSpecCache(doc: OpenApiDocument): void {
100
+ rawSpecCache = doc
101
+ }
102
+
103
+ /**
104
+ * Clear the raw OpenAPI spec cache.
105
+ */
106
+ export function clearRawSpecCache(): void {
107
+ rawSpecCache = null
108
+ }
109
+
110
+ /**
111
+ * Load the rich OpenAPI spec, skipping Tier 1 (static JSON) which lacks requestBody schemas.
112
+ * Prefers Tier 2 (runtime module registry) which has full Zod-converted schemas.
113
+ * Falls back to Tier 1 then Tier 3 if needed.
114
+ */
115
+ export async function loadRichOpenApiSpec(): Promise<OpenApiDocument | null> {
116
+ if (rawSpecCache) return rawSpecCache
117
+
118
+ // Tier 2 first: Module registry (has full Zod-converted schemas)
119
+ try {
120
+ const { getModules } = await import('@open-mercato/shared/lib/modules/registry')
121
+ const modules: Module[] = getModules()
122
+ const modulesWithApis = modules.filter((m) => m.apis && m.apis.length > 0)
123
+
124
+ if (modulesWithApis.length > 0) {
125
+ const doc = buildOpenApiDocument(modules, {
126
+ title: 'Open Mercato API',
127
+ version: '1.0.0',
128
+ servers: [{ url: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }],
129
+ })
130
+ console.error(`[API Index] Rich OpenAPI spec built from ${modulesWithApis.length} modules (Tier 2)`)
131
+ rawSpecCache = doc
132
+ return doc
133
+ }
134
+ } catch {
135
+ // Registry not available — fall through
136
+ }
137
+
138
+ // Fall back to standard 3-tier loading (Tier 1 → Tier 3)
139
+ rawSpecCache = await loadRawOpenApiSpec()
140
+ return rawSpecCache
141
+ }
142
+
143
+ /**
144
+ * Load raw OpenAPI spec using the 3-tier strategy.
145
+ */
146
+ async function loadRawOpenApiSpec(): Promise<OpenApiDocument | null> {
147
+ // Tier 1: Generated JSON file
148
+ try {
149
+ const fs = await import('node:fs')
150
+ const path = await import('node:path')
151
+ const { findAppRoot, findAllApps } = await import('@open-mercato/shared/lib/bootstrap/appResolver')
152
+
153
+ let appRoot = findAppRoot()
154
+ if (!appRoot) {
155
+ let current = process.cwd()
156
+ while (current !== path.dirname(current)) {
157
+ const appsDir = path.join(current, 'apps')
158
+ if (fs.existsSync(appsDir)) {
159
+ const apps = findAllApps(current)
160
+ if (apps.length > 0) {
161
+ appRoot = apps[0]
162
+ break
163
+ }
164
+ }
165
+ current = path.dirname(current)
166
+ }
167
+ }
168
+
169
+ if (appRoot) {
170
+ const jsonPath = path.join(appRoot.generatedDir, 'openapi.generated.json')
171
+ if (fs.existsSync(jsonPath)) {
172
+ const doc = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as OpenApiDocument
173
+ console.error(`[API Index] Raw OpenAPI spec loaded from ${jsonPath}`)
174
+ return doc
175
+ }
176
+ }
177
+ } catch (error) {
178
+ console.error('[API Index] Raw spec from JSON failed:', error instanceof Error ? error.message : error)
179
+ }
180
+
181
+ // Tier 2: Module registry
182
+ try {
183
+ const { getModules } = await import('@open-mercato/shared/lib/modules/registry')
184
+ const modules: Module[] = getModules()
185
+ const modulesWithApis = modules.filter((m) => m.apis && m.apis.length > 0)
186
+
187
+ if (modulesWithApis.length > 0) {
188
+ const doc = buildOpenApiDocument(modules, {
189
+ title: 'Open Mercato API',
190
+ version: '1.0.0',
191
+ servers: [{ url: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }],
192
+ })
193
+ console.error(`[API Index] Raw OpenAPI spec built from ${modulesWithApis.length} modules`)
194
+ return doc
195
+ }
196
+ } catch {
197
+ // Registry not available
198
+ }
199
+
200
+ // Tier 3: HTTP fetch
201
+ const baseUrl =
202
+ process.env.NEXT_PUBLIC_API_BASE_URL ||
203
+ process.env.NEXT_PUBLIC_APP_URL ||
204
+ process.env.APP_URL ||
205
+ 'http://localhost:3000'
206
+
207
+ try {
208
+ const response = await fetch(`${baseUrl}/api/docs/openapi`)
209
+ if (response.ok) {
210
+ const doc = (await response.json()) as OpenApiDocument
211
+ console.error('[API Index] Raw OpenAPI spec fetched via HTTP')
212
+ return doc
213
+ }
214
+ } catch (error) {
215
+ console.error('[API Index] Raw spec HTTP fetch failed:', error instanceof Error ? error.message : error)
216
+ }
217
+
218
+ return null
219
+ }
220
+
79
221
  /**
80
222
  * Parse endpoints from generated OpenAPI JSON file (for CLI context).
81
223
  * This is generated by `yarn modules:prepare` or `yarn generate all`.
@@ -484,6 +626,7 @@ function searchEndpointsFallback(
484
626
  export function clearEndpointCache(): void {
485
627
  endpointsCache = null
486
628
  endpointsByOperationId = null
629
+ rawSpecCache = null
487
630
  }
488
631
 
489
632
  /**