@open-mercato/ai-assistant 0.4.9-develop-8c36c096d5 → 0.4.9-develop-be6e1cf49c
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,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
|
/**
|