@objectstack/service-ai 4.0.0

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.
@@ -0,0 +1,205 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ AIMessage,
5
+ AIRequestOptions,
6
+ AIResult,
7
+ AIStreamEvent,
8
+ IAIService,
9
+ IAIConversationService,
10
+ ChatWithToolsOptions,
11
+ LLMAdapter,
12
+ } from '@objectstack/spec/contracts';
13
+ import type { Logger } from '@objectstack/spec/contracts';
14
+ import { createLogger } from '@objectstack/core';
15
+ import { MemoryLLMAdapter } from './adapters/memory-adapter.js';
16
+ import { ToolRegistry } from './tools/tool-registry.js';
17
+ import { InMemoryConversationService } from './conversation/in-memory-conversation-service.js';
18
+
19
+ /**
20
+ * Configuration for AIService.
21
+ */
22
+ export interface AIServiceConfig {
23
+ /** LLM adapter to delegate calls to (defaults to MemoryLLMAdapter). */
24
+ adapter?: LLMAdapter;
25
+ /** Logger instance. */
26
+ logger?: Logger;
27
+ /** Pre-registered tools. */
28
+ toolRegistry?: ToolRegistry;
29
+ /** Conversation service (defaults to InMemoryConversationService). */
30
+ conversationService?: IAIConversationService;
31
+ }
32
+
33
+ /**
34
+ * AIService — Unified AI capability service.
35
+ *
36
+ * Implements {@link IAIService} by delegating to a pluggable {@link LLMAdapter}
37
+ * and managing tools and conversations through dedicated sub-components:
38
+ *
39
+ * | Component | Responsibility |
40
+ * |:---|:---|
41
+ * | {@link LLMAdapter} | LLM provider abstraction (chat, complete, stream, embed) |
42
+ * | {@link ToolRegistry} | Tool definition storage & execution |
43
+ * | {@link IAIConversationService} | Conversation CRUD & message persistence |
44
+ *
45
+ * The service is registered as `'ai'` in the kernel service registry by
46
+ * the {@link AIServicePlugin}.
47
+ */
48
+ export class AIService implements IAIService {
49
+ private readonly adapter: LLMAdapter;
50
+ private readonly logger: Logger;
51
+ readonly toolRegistry: ToolRegistry;
52
+ readonly conversationService: IAIConversationService;
53
+
54
+ constructor(config: AIServiceConfig = {}) {
55
+ this.adapter = config.adapter ?? new MemoryLLMAdapter();
56
+ this.logger = config.logger ?? createLogger({ level: 'info', format: 'pretty' });
57
+ this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
58
+ this.conversationService = config.conversationService ?? new InMemoryConversationService();
59
+
60
+ this.logger.info(
61
+ `[AI] Service initialized with adapter="${this.adapter.name}", ` +
62
+ `tools=${this.toolRegistry.size}`,
63
+ );
64
+ }
65
+
66
+ /** The name of the active LLM adapter. */
67
+ get adapterName(): string {
68
+ return this.adapter.name;
69
+ }
70
+
71
+ // ── IAIService implementation ──────────────────────────────────
72
+
73
+ async chat(messages: AIMessage[], options?: AIRequestOptions): Promise<AIResult> {
74
+ this.logger.debug('[AI] chat', { messageCount: messages.length, model: options?.model });
75
+ return this.adapter.chat(messages, options);
76
+ }
77
+
78
+ async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
79
+ this.logger.debug('[AI] complete', { promptLength: prompt.length, model: options?.model });
80
+ return this.adapter.complete(prompt, options);
81
+ }
82
+
83
+ async *streamChat(
84
+ messages: AIMessage[],
85
+ options?: AIRequestOptions,
86
+ ): AsyncIterable<AIStreamEvent> {
87
+ this.logger.debug('[AI] streamChat', { messageCount: messages.length, model: options?.model });
88
+
89
+ if (!this.adapter.streamChat) {
90
+ // Fallback: emit the entire response as a single text-delta + finish
91
+ const result = await this.adapter.chat(messages, options);
92
+ yield { type: 'text-delta', textDelta: result.content };
93
+ yield { type: 'finish', result };
94
+ return;
95
+ }
96
+
97
+ yield* this.adapter.streamChat(messages, options);
98
+ }
99
+
100
+ async embed(input: string | string[], model?: string): Promise<number[][]> {
101
+ if (!this.adapter.embed) {
102
+ throw new Error(`[AI] Adapter "${this.adapter.name}" does not support embeddings`);
103
+ }
104
+ return this.adapter.embed(input, model);
105
+ }
106
+
107
+ async listModels(): Promise<string[]> {
108
+ if (!this.adapter.listModels) {
109
+ return [];
110
+ }
111
+ return this.adapter.listModels();
112
+ }
113
+
114
+ // ── Tool Call Loop ────────────────────────────────────────────
115
+
116
+ /** Default maximum iterations for the tool call loop. */
117
+ static readonly DEFAULT_MAX_ITERATIONS = 10;
118
+
119
+ /**
120
+ * Chat with automatic tool call resolution.
121
+ *
122
+ * 1. Merges registered tool definitions into `options.tools`.
123
+ * 2. Calls the LLM adapter.
124
+ * 3. If the response contains `toolCalls`, executes them via the
125
+ * {@link ToolRegistry}, appends tool results as `role: 'tool'`
126
+ * messages, and loops back to step 2.
127
+ * 4. Repeats until the model produces a final text response or the
128
+ * maximum number of iterations (`maxIterations`) is reached.
129
+ */
130
+ async chatWithTools(
131
+ messages: AIMessage[],
132
+ options?: ChatWithToolsOptions,
133
+ ): Promise<AIResult> {
134
+ // Destructure maxIterations out so it is never forwarded to the adapter
135
+ const { maxIterations: maxIter, ...restOptions } = options ?? {};
136
+ const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS;
137
+ const registeredTools = this.toolRegistry.getAll();
138
+
139
+ // Merge registered tools with any explicitly provided tools
140
+ const mergedTools = [
141
+ ...registeredTools,
142
+ ...(restOptions.tools ?? []),
143
+ ];
144
+
145
+ // Build the options that will be sent to every LLM call in the loop
146
+ const chatOptions: AIRequestOptions = {
147
+ ...restOptions,
148
+ tools: mergedTools.length > 0 ? mergedTools : undefined,
149
+ toolChoice: mergedTools.length > 0 ? (restOptions.toolChoice ?? 'auto') : undefined,
150
+ };
151
+
152
+ // Working copy of the conversation
153
+ const conversation = [...messages];
154
+
155
+ this.logger.debug('[AI] chatWithTools start', {
156
+ messageCount: conversation.length,
157
+ toolCount: mergedTools.length,
158
+ maxIterations,
159
+ });
160
+
161
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
162
+ const result = await this.adapter.chat(conversation, chatOptions);
163
+
164
+ // If the model did not request any tool calls we're done
165
+ if (!result.toolCalls || result.toolCalls.length === 0) {
166
+ this.logger.debug('[AI] chatWithTools finished', { iteration, content: result.content.slice(0, 80) });
167
+ return result;
168
+ }
169
+
170
+ this.logger.debug('[AI] chatWithTools tool calls', {
171
+ iteration,
172
+ calls: result.toolCalls.map(tc => tc.name),
173
+ });
174
+
175
+ // Append the assistant's response (with tool call metadata) to the conversation
176
+ conversation.push({
177
+ role: 'assistant',
178
+ content: result.content ?? '',
179
+ toolCalls: result.toolCalls,
180
+ });
181
+
182
+ // Execute all tool calls in parallel
183
+ const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
184
+
185
+ // Append each tool result as a `role: 'tool'` message
186
+ for (const tr of toolResults) {
187
+ conversation.push({
188
+ role: 'tool',
189
+ content: tr.content,
190
+ toolCallId: tr.toolCallId,
191
+ });
192
+ }
193
+ }
194
+
195
+ // If we exhausted the loop without a final response, make one last
196
+ // call *without* tools so the model is forced to produce text.
197
+ this.logger.warn('[AI] chatWithTools max iterations reached, forcing final response');
198
+ const finalResult = await this.adapter.chat(conversation, {
199
+ ...chatOptions,
200
+ tools: undefined,
201
+ toolChoice: undefined,
202
+ });
203
+ return finalResult;
204
+ }
205
+ }
@@ -0,0 +1,103 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ AIConversation,
5
+ AIMessage,
6
+ IAIConversationService,
7
+ } from '@objectstack/spec/contracts';
8
+
9
+ /**
10
+ * InMemoryConversationService — Reference implementation of IAIConversationService.
11
+ *
12
+ * Stores conversations in a simple Map. Suitable for development, testing,
13
+ * and single-process deployments. Production environments should replace
14
+ * this with a persistent implementation (e.g., backed by ObjectQL/SQL).
15
+ */
16
+ export class InMemoryConversationService implements IAIConversationService {
17
+ private readonly store = new Map<string, AIConversation>();
18
+ private counter = 0;
19
+
20
+ async create(options: {
21
+ title?: string;
22
+ agentId?: string;
23
+ userId?: string;
24
+ metadata?: Record<string, unknown>;
25
+ } = {}): Promise<AIConversation> {
26
+ const now = new Date().toISOString();
27
+ const id = `conv_${++this.counter}`;
28
+
29
+ const conversation: AIConversation = {
30
+ id,
31
+ title: options.title,
32
+ agentId: options.agentId,
33
+ userId: options.userId,
34
+ messages: [],
35
+ createdAt: now,
36
+ updatedAt: now,
37
+ metadata: options.metadata,
38
+ };
39
+
40
+ this.store.set(id, conversation);
41
+ return conversation;
42
+ }
43
+
44
+ async get(conversationId: string): Promise<AIConversation | null> {
45
+ return this.store.get(conversationId) ?? null;
46
+ }
47
+
48
+ async list(options: {
49
+ userId?: string;
50
+ agentId?: string;
51
+ limit?: number;
52
+ cursor?: string;
53
+ } = {}): Promise<AIConversation[]> {
54
+ let results = Array.from(this.store.values());
55
+
56
+ if (options.userId) {
57
+ results = results.filter(c => c.userId === options.userId);
58
+ }
59
+ if (options.agentId) {
60
+ results = results.filter(c => c.agentId === options.agentId);
61
+ }
62
+
63
+ // Simple cursor-based pagination: cursor = conversation ID
64
+ if (options.cursor) {
65
+ const idx = results.findIndex(c => c.id === options.cursor);
66
+ if (idx >= 0) {
67
+ results = results.slice(idx + 1);
68
+ }
69
+ }
70
+
71
+ if (options.limit && options.limit > 0) {
72
+ results = results.slice(0, options.limit);
73
+ }
74
+
75
+ return results;
76
+ }
77
+
78
+ async addMessage(conversationId: string, message: AIMessage): Promise<AIConversation> {
79
+ const conversation = this.store.get(conversationId);
80
+ if (!conversation) {
81
+ throw new Error(`Conversation "${conversationId}" not found`);
82
+ }
83
+
84
+ conversation.messages.push(message);
85
+ conversation.updatedAt = new Date().toISOString();
86
+ return conversation;
87
+ }
88
+
89
+ async delete(conversationId: string): Promise<void> {
90
+ this.store.delete(conversationId);
91
+ }
92
+
93
+ /** Total number of stored conversations. */
94
+ get size(): number {
95
+ return this.store.size;
96
+ }
97
+
98
+ /** Clear all conversations. */
99
+ clear(): void {
100
+ this.store.clear();
101
+ this.counter = 0;
102
+ }
103
+ }
@@ -0,0 +1,4 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ export { InMemoryConversationService } from './in-memory-conversation-service.js';
4
+ export { ObjectQLConversationService } from './objectql-conversation-service.js';
@@ -0,0 +1,252 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+ import type {
5
+ AIConversation,
6
+ AIMessage,
7
+ IAIConversationService,
8
+ IDataEngine,
9
+ } from '@objectstack/spec/contracts';
10
+
11
+ /** Object names used for persistence. */
12
+ const CONVERSATIONS_OBJECT = 'ai_conversations';
13
+ const MESSAGES_OBJECT = 'ai_messages';
14
+
15
+ /** Database row shape for ai_conversations. */
16
+ interface DbConversationRow {
17
+ id: string;
18
+ title: string | null;
19
+ agent_id: string | null;
20
+ user_id: string | null;
21
+ metadata: string | null;
22
+ created_at: string;
23
+ updated_at: string;
24
+ }
25
+
26
+ /** Database row shape for ai_messages. */
27
+ interface DbMessageRow {
28
+ id: string;
29
+ conversation_id: string;
30
+ role: 'system' | 'user' | 'assistant' | 'tool';
31
+ content: string;
32
+ tool_calls: string | null;
33
+ tool_call_id: string | null;
34
+ created_at: string;
35
+ }
36
+
37
+ /** Deterministic ordering for conversations (total order). */
38
+ const CONVERSATION_ORDER = [
39
+ { field: 'created_at', order: 'asc' as const },
40
+ { field: 'id', order: 'asc' as const },
41
+ ];
42
+
43
+ /** Deterministic ordering for messages within a conversation. */
44
+ const MESSAGE_ORDER = [
45
+ { field: 'created_at', order: 'asc' as const },
46
+ { field: 'id', order: 'asc' as const },
47
+ ];
48
+
49
+ /**
50
+ * ObjectQLConversationService — Persistent implementation of IAIConversationService.
51
+ *
52
+ * Delegates all storage to an {@link IDataEngine} instance, using the
53
+ * `ai_conversations` and `ai_messages` objects. This decouples the service
54
+ * from any specific database driver (Turso, Postgres, SQLite, etc.).
55
+ *
56
+ * Production environments should use this implementation to ensure
57
+ * conversation history survives service restarts.
58
+ */
59
+ export class ObjectQLConversationService implements IAIConversationService {
60
+ private readonly engine: IDataEngine;
61
+
62
+ constructor(engine: IDataEngine) {
63
+ this.engine = engine;
64
+ }
65
+
66
+ async create(options: {
67
+ title?: string;
68
+ agentId?: string;
69
+ userId?: string;
70
+ metadata?: Record<string, unknown>;
71
+ } = {}): Promise<AIConversation> {
72
+ const now = new Date().toISOString();
73
+ const id = `conv_${randomUUID()}`;
74
+
75
+ const record = {
76
+ id,
77
+ title: options.title ?? null,
78
+ agent_id: options.agentId ?? null,
79
+ user_id: options.userId ?? null,
80
+ metadata: options.metadata ? JSON.stringify(options.metadata) : null,
81
+ created_at: now,
82
+ updated_at: now,
83
+ };
84
+
85
+ await this.engine.insert(CONVERSATIONS_OBJECT, record);
86
+
87
+ return {
88
+ id,
89
+ title: options.title,
90
+ agentId: options.agentId,
91
+ userId: options.userId,
92
+ messages: [],
93
+ createdAt: now,
94
+ updatedAt: now,
95
+ metadata: options.metadata,
96
+ };
97
+ }
98
+
99
+ async get(conversationId: string): Promise<AIConversation | null> {
100
+ const row: DbConversationRow | null = await this.engine.findOne(CONVERSATIONS_OBJECT, {
101
+ where: { id: conversationId },
102
+ });
103
+
104
+ if (!row) return null;
105
+
106
+ const messages: DbMessageRow[] = await this.engine.find(MESSAGES_OBJECT, {
107
+ where: { conversation_id: conversationId },
108
+ orderBy: MESSAGE_ORDER,
109
+ });
110
+
111
+ return this.toConversation(row, messages);
112
+ }
113
+
114
+ async list(options: {
115
+ userId?: string;
116
+ agentId?: string;
117
+ limit?: number;
118
+ cursor?: string;
119
+ } = {}): Promise<AIConversation[]> {
120
+ const where: Record<string, unknown> = {};
121
+ if (options.userId) where.user_id = options.userId;
122
+ if (options.agentId) where.agent_id = options.agentId;
123
+
124
+ // Stable cursor-based pagination using composite (created_at, id) order.
125
+ // This avoids skips/duplicates when multiple conversations share a timestamp.
126
+ if (options.cursor) {
127
+ const cursorRow = await this.engine.findOne(CONVERSATIONS_OBJECT, {
128
+ where: { id: options.cursor },
129
+ fields: ['created_at', 'id'],
130
+ });
131
+ if (cursorRow) {
132
+ where.$or = [
133
+ { created_at: { $gt: cursorRow.created_at } },
134
+ { created_at: cursorRow.created_at, id: { $gt: cursorRow.id } },
135
+ ];
136
+ }
137
+ }
138
+
139
+ const rows: DbConversationRow[] = await this.engine.find(CONVERSATIONS_OBJECT, {
140
+ where: Object.keys(where).length > 0 ? where : undefined,
141
+ orderBy: CONVERSATION_ORDER,
142
+ limit: options.limit && options.limit > 0 ? options.limit : undefined,
143
+ });
144
+
145
+ // Load messages per conversation in parallel.
146
+ // N+1 is bounded by the pagination limit; driver-agnostic $in is not guaranteed.
147
+ const conversations: AIConversation[] = await Promise.all(
148
+ rows.map(async (row) => {
149
+ const messages: DbMessageRow[] = await this.engine.find(MESSAGES_OBJECT, {
150
+ where: { conversation_id: row.id },
151
+ orderBy: MESSAGE_ORDER,
152
+ });
153
+ return this.toConversation(row, messages);
154
+ }),
155
+ );
156
+
157
+ return conversations;
158
+ }
159
+
160
+ async addMessage(conversationId: string, message: AIMessage): Promise<AIConversation> {
161
+ // Verify conversation exists
162
+ const row: DbConversationRow | null = await this.engine.findOne(CONVERSATIONS_OBJECT, {
163
+ where: { id: conversationId },
164
+ });
165
+ if (!row) {
166
+ throw new Error(`Conversation "${conversationId}" not found`);
167
+ }
168
+
169
+ const now = new Date().toISOString();
170
+ const msgId = `msg_${randomUUID()}`;
171
+
172
+ // Insert the message
173
+ await this.engine.insert(MESSAGES_OBJECT, {
174
+ id: msgId,
175
+ conversation_id: conversationId,
176
+ role: message.role,
177
+ content: message.content,
178
+ tool_calls: message.toolCalls ? JSON.stringify(message.toolCalls) : null,
179
+ tool_call_id: message.toolCallId ?? null,
180
+ created_at: now,
181
+ });
182
+
183
+ // Update conversation timestamp
184
+ await this.engine.update(CONVERSATIONS_OBJECT, { id: conversationId, updated_at: now }, {
185
+ where: { id: conversationId },
186
+ });
187
+
188
+ // Return the full updated conversation
189
+ return (await this.get(conversationId))!;
190
+ }
191
+
192
+ async delete(conversationId: string): Promise<void> {
193
+ // Delete messages first (child records)
194
+ await this.engine.delete(MESSAGES_OBJECT, {
195
+ where: { conversation_id: conversationId },
196
+ multi: true,
197
+ });
198
+
199
+ // Delete the conversation
200
+ await this.engine.delete(CONVERSATIONS_OBJECT, {
201
+ where: { id: conversationId },
202
+ });
203
+ }
204
+
205
+ // ── Private helpers ──────────────────────────────────────────────
206
+
207
+ /**
208
+ * Safely parse a JSON string, returning `undefined` on failure.
209
+ */
210
+ private safeParse<T>(value: string | null, fallback?: T): T | undefined {
211
+ if (!value) return undefined;
212
+ try {
213
+ return JSON.parse(value) as T;
214
+ } catch {
215
+ return fallback;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Map a database row + message rows to an AIConversation.
221
+ */
222
+ private toConversation(row: DbConversationRow, messageRows: DbMessageRow[]): AIConversation {
223
+ return {
224
+ id: row.id,
225
+ title: row.title ?? undefined,
226
+ agentId: row.agent_id ?? undefined,
227
+ userId: row.user_id ?? undefined,
228
+ messages: messageRows.map(m => this.toMessage(m)),
229
+ createdAt: row.created_at,
230
+ updatedAt: row.updated_at,
231
+ metadata: this.safeParse<Record<string, unknown>>(row.metadata),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Map a database row to an AIMessage.
237
+ */
238
+ private toMessage(row: DbMessageRow): AIMessage {
239
+ const msg: AIMessage = {
240
+ role: row.role,
241
+ content: row.content,
242
+ };
243
+ const toolCalls = this.safeParse<any[]>(row.tool_calls);
244
+ if (toolCalls) {
245
+ msg.toolCalls = toolCalls;
246
+ }
247
+ if (row.tool_call_id) {
248
+ msg.toolCallId = row.tool_call_id;
249
+ }
250
+ return msg;
251
+ }
252
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ // Core service
4
+ export { AIService } from './ai-service.js';
5
+ export type { AIServiceConfig } from './ai-service.js';
6
+
7
+ // Kernel plugin
8
+ export { AIServicePlugin } from './plugin.js';
9
+ export type { AIServicePluginOptions } from './plugin.js';
10
+
11
+ // Adapters
12
+ export { MemoryLLMAdapter } from './adapters/memory-adapter.js';
13
+ export type { LLMAdapter } from '@objectstack/spec/contracts';
14
+
15
+ // Conversation
16
+ export { InMemoryConversationService } from './conversation/in-memory-conversation-service.js';
17
+ export { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
18
+
19
+ // Tool registry
20
+ export { ToolRegistry } from './tools/tool-registry.js';
21
+ export type { ToolHandler } from './tools/tool-registry.js';
22
+
23
+ // Data tools
24
+ export { registerDataTools, DATA_TOOL_DEFINITIONS } from './tools/data-tools.js';
25
+ export type { DataToolContext } from './tools/data-tools.js';
26
+
27
+ // Agent runtime
28
+ export { AgentRuntime } from './agent-runtime.js';
29
+ export type { AgentChatContext } from './agent-runtime.js';
30
+
31
+ // Built-in agents
32
+ export { DATA_CHAT_AGENT } from './agents/index.js';
33
+
34
+ // Object definitions
35
+ export { AiConversationObject, AiMessageObject } from './objects/index.js';
36
+
37
+ // Routes
38
+ export { buildAIRoutes } from './routes/ai-routes.js';
39
+ export { buildAgentRoutes } from './routes/agent-routes.js';
40
+ export type { RouteDefinition, RouteRequest, RouteResponse } from './routes/ai-routes.js';
@@ -0,0 +1,86 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * ai_conversations — AI Conversation Object
7
+ *
8
+ * Stores conversation metadata for persistent AI conversation management.
9
+ * Messages are stored separately in `ai_messages` to support efficient
10
+ * querying and pagination.
11
+ *
12
+ * @namespace ai
13
+ */
14
+ export const AiConversationObject = ObjectSchema.create({
15
+ namespace: 'ai',
16
+ name: 'conversations',
17
+ label: 'AI Conversation',
18
+ pluralLabel: 'AI Conversations',
19
+ icon: 'message-square',
20
+ isSystem: true,
21
+ description: 'Persistent AI conversation metadata',
22
+
23
+ fields: {
24
+ id: Field.text({
25
+ label: 'Conversation ID',
26
+ required: true,
27
+ readonly: true,
28
+ }),
29
+
30
+ title: Field.text({
31
+ label: 'Title',
32
+ required: false,
33
+ maxLength: 500,
34
+ description: 'Conversation title or summary',
35
+ }),
36
+
37
+ agent_id: Field.text({
38
+ label: 'Agent ID',
39
+ required: false,
40
+ maxLength: 255,
41
+ description: 'Associated AI agent identifier',
42
+ }),
43
+
44
+ user_id: Field.text({
45
+ label: 'User ID',
46
+ required: false,
47
+ maxLength: 255,
48
+ description: 'User who owns the conversation',
49
+ }),
50
+
51
+ metadata: Field.textarea({
52
+ label: 'Metadata',
53
+ required: false,
54
+ description: 'JSON-serialized conversation metadata',
55
+ }),
56
+
57
+ created_at: Field.datetime({
58
+ label: 'Created At',
59
+ required: true,
60
+ defaultValue: 'NOW()',
61
+ readonly: true,
62
+ }),
63
+
64
+ updated_at: Field.datetime({
65
+ label: 'Updated At',
66
+ required: true,
67
+ defaultValue: 'NOW()',
68
+ readonly: true,
69
+ }),
70
+ },
71
+
72
+ indexes: [
73
+ { fields: ['user_id'] },
74
+ { fields: ['agent_id'] },
75
+ { fields: ['created_at'] },
76
+ ],
77
+
78
+ enable: {
79
+ trackHistory: false,
80
+ searchable: false,
81
+ apiEnabled: true,
82
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
83
+ trash: false,
84
+ mru: false,
85
+ },
86
+ });