@node-llm/orm 0.3.0 → 0.5.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.
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@node-llm/orm",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Database persistence layer for NodeLLM - Chat, Message, and ToolCall tracking with streaming support",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
5
8
  "type": "module",
6
9
  "main": "./dist/index.js",
7
10
  "types": "./dist/index.d.ts",
@@ -53,7 +56,7 @@
53
56
  "author": "NodeLLM Contributors",
54
57
  "license": "MIT",
55
58
  "peerDependencies": {
56
- "@node-llm/core": "^1.9.0",
59
+ "@node-llm/core": "^1.10.0",
57
60
  "@prisma/client": "^5.0.0"
58
61
  },
59
62
  "devDependencies": {
@@ -68,6 +71,7 @@
68
71
  "scripts": {
69
72
  "build": "prisma generate --schema=schema.prisma && tsc",
70
73
  "test": "vitest run",
74
+ "test:docs": "vitest run test/docs",
71
75
  "test:watch": "vitest"
72
76
  }
73
77
  }
package/schema.prisma CHANGED
@@ -12,53 +12,70 @@ datasource db {
12
12
 
13
13
  // NodeLLM ORM Models (matches @node-llm/orm schema)
14
14
  model LlmChat {
15
- id String @id @default(uuid())
15
+ id String @id @default(uuid())
16
16
  model String?
17
17
  provider String?
18
- instructions String? // System instructions
19
- metadata Json? // JSON metadata
20
- createdAt DateTime @default(now())
21
- updatedAt DateTime @updatedAt
18
+ instructions String? // System instructions
19
+ metadata Json? // JSON metadata
20
+ createdAt DateTime @default(now())
21
+ updatedAt DateTime @updatedAt
22
22
  messages LlmMessage[]
23
23
  requests LlmRequest[]
24
+ agentSession LlmAgentSession?
25
+ }
26
+
27
+ // Agent Session - Links Agent class to persistent Chat
28
+ // "Code defines behavior, DB provides history"
29
+ model LlmAgentSession {
30
+ id String @id @default(uuid())
31
+ agentClass String // Class name for validation (e.g., 'SupportAgent')
32
+ chatId String @unique
33
+ metadata Json? // Session context (userId, ticketId, etc.)
34
+ createdAt DateTime @default(now())
35
+ updatedAt DateTime @updatedAt
36
+
37
+ chat LlmChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
38
+
39
+ @@index([agentClass])
40
+ @@index([createdAt])
24
41
  }
25
42
 
26
43
  model LlmMessage {
27
- id String @id @default(uuid())
44
+ id String @id @default(uuid())
28
45
  chatId String
29
- role String // user, assistant, system, tool
46
+ role String // user, assistant, system, tool
30
47
  content String?
31
- contentRaw String? // JSON raw payload
32
- reasoning String? // Chain of thought (deprecated)
33
- thinkingText String? // Extended thinking text
34
- thinkingSignature String? // Cryptographic signature
35
- thinkingTokens Int? // Tokens spent on thinking
48
+ contentRaw String? // JSON raw payload
49
+ reasoning String? // Chain of thought (deprecated)
50
+ thinkingText String? // Extended thinking text
51
+ thinkingSignature String? // Cryptographic signature
52
+ thinkingTokens Int? // Tokens spent on thinking
36
53
  inputTokens Int?
37
54
  outputTokens Int?
38
55
  modelId String?
39
56
  provider String?
40
- createdAt DateTime @default(now())
57
+ createdAt DateTime @default(now())
41
58
 
42
- chat LlmChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
43
- toolCalls LlmToolCall[]
44
- requests LlmRequest[]
59
+ chat LlmChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
60
+ toolCalls LlmToolCall[]
61
+ requests LlmRequest[]
45
62
 
46
63
  @@index([chatId])
47
64
  @@index([createdAt])
48
65
  }
49
66
 
50
67
  model LlmToolCall {
51
- id String @id @default(uuid())
68
+ id String @id @default(uuid())
52
69
  messageId String
53
- toolCallId String // ID from the provider
70
+ toolCallId String // ID from the provider
54
71
  name String
55
- arguments String // JSON string
56
- thought String? // The LLM's reasoning for this tool call
57
- thoughtSignature String? // Signature for the thought
58
- result String? // Tool execution result
59
- createdAt DateTime @default(now())
72
+ arguments String // JSON string
73
+ thought String? // The LLM's reasoning for this tool call
74
+ thoughtSignature String? // Signature for the thought
75
+ result String? // Tool execution result
76
+ createdAt DateTime @default(now())
60
77
 
61
- message LlmMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
78
+ message LlmMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
62
79
 
63
80
  @@unique([messageId, toolCallId])
64
81
  @@index([messageId])
@@ -66,22 +83,22 @@ model LlmToolCall {
66
83
  }
67
84
 
68
85
  model LlmRequest {
69
- id String @id @default(uuid())
70
- chatId String
71
- messageId String? // Optional because requests might fail before message creation
72
-
86
+ id String @id @default(uuid())
87
+ chatId String
88
+ messageId String? // Optional because requests might fail before message creation
89
+
73
90
  provider String
74
91
  model String
75
92
  statusCode Int
76
- duration Int // milliseconds
93
+ duration Int // milliseconds
77
94
  inputTokens Int
78
95
  outputTokens Int
79
96
  cost Float?
80
-
81
- createdAt DateTime @default(now())
82
97
 
83
- chat LlmChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
84
- message LlmMessage? @relation(fields: [messageId], references: [id], onDelete: Cascade)
98
+ createdAt DateTime @default(now())
99
+
100
+ chat LlmChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
101
+ message LlmMessage? @relation(fields: [messageId], references: [id], onDelete: Cascade)
85
102
 
86
103
  @@index([chatId])
87
104
  @@index([createdAt])
package/src/BaseChat.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import type { Usage } from "@node-llm/core";
2
+ import type { Usage, Middleware } from "@node-llm/core";
3
3
 
4
4
  export interface ChatRecord {
5
5
  id: string;
@@ -31,6 +31,7 @@ export interface ChatOptions {
31
31
  maxToolCalls?: number;
32
32
  requestTimeout?: number;
33
33
  params?: Record<string, any>;
34
+ middlewares?: Middleware[];
34
35
  }
35
36
 
36
37
  export interface UserHooks {
@@ -52,6 +53,7 @@ export abstract class BaseChat<
52
53
  public id: string;
53
54
  protected localOptions: any = {};
54
55
  protected customTools: any[] = [];
56
+ protected customMiddlewares: Middleware[] = [];
55
57
  protected userHooks: UserHooks = {
56
58
  onToolCallStart: [],
57
59
  onToolCallEnd: [],
@@ -78,6 +80,11 @@ export abstract class BaseChat<
78
80
  this.localOptions.maxToolCalls = options.maxToolCalls;
79
81
  this.localOptions.requestTimeout = options.requestTimeout;
80
82
  this.localOptions.params = options.params;
83
+
84
+ // Initialize middlewares
85
+ if (options.middlewares) {
86
+ this.customMiddlewares = options.middlewares;
87
+ }
81
88
  }
82
89
 
83
90
  protected log(...args: any[]) {
@@ -0,0 +1,458 @@
1
+ import {
2
+ ChatOptions,
3
+ AskOptions,
4
+ NodeLLMCore,
5
+ Agent,
6
+ AgentConfig,
7
+ Message,
8
+ ChatChunk,
9
+ Usage
10
+ } from "@node-llm/core";
11
+
12
+ /**
13
+ * Internal interface for dynamic Prisma Client access.
14
+ * We use 'any' here because PrismaClient has no index signature by default,
15
+ * making it hard to access models dynamically by string name.
16
+ */
17
+ type GenericPrismaClient = any;
18
+
19
+ /**
20
+ * Record structure for the LLM Agent Session table.
21
+ */
22
+ export interface AgentSessionRecord {
23
+ id: string;
24
+ agentClass: string;
25
+ chatId: string;
26
+ metadata?: Record<string, unknown> | null;
27
+ createdAt: Date;
28
+ updatedAt: Date;
29
+ }
30
+
31
+ /**
32
+ * Record structure for the LLM Message table.
33
+ */
34
+ export interface MessageRecord {
35
+ id: string;
36
+ chatId: string;
37
+ role: string;
38
+ content: string | null;
39
+ contentRaw?: string | null;
40
+ thinkingText?: string | null;
41
+ thinkingSignature?: string | null;
42
+ thinkingTokens?: number | null;
43
+ inputTokens?: number | null;
44
+ outputTokens?: number | null;
45
+ modelId?: string | null;
46
+ provider?: string | null;
47
+ createdAt: Date;
48
+ }
49
+
50
+ /**
51
+ * Table name customization.
52
+ */
53
+ export interface TableNames {
54
+ agentSession?: string;
55
+ chat?: string;
56
+ message?: string;
57
+ toolCall?: string;
58
+ request?: string;
59
+ }
60
+
61
+ /**
62
+ * Internal interface for dynamic Prisma model access
63
+ */
64
+ interface PrismaModel<T = Record<string, unknown>> {
65
+ create(args: { data: Record<string, unknown> }): Promise<T>;
66
+ update(args: { where: { id: string }; data: Record<string, unknown> }): Promise<T>;
67
+ delete(args: { where: { id: string } }): Promise<void>;
68
+ findMany(args: {
69
+ where: Record<string, unknown>;
70
+ orderBy?: Record<string, string>;
71
+ }): Promise<T[]>;
72
+ findUnique(args: { where: { id: string } }): Promise<T | null>;
73
+ }
74
+
75
+ type AgentClass<T extends Agent = Agent> = (new (
76
+ overrides?: Partial<AgentConfig & ChatOptions>
77
+ ) => T) & {
78
+ name: string;
79
+ model?: string;
80
+ instructions?: string;
81
+ tools?: unknown[];
82
+ };
83
+
84
+ /**
85
+ * AgentSession - Wraps an Agent instance with persistence capabilities.
86
+ *
87
+ * Follows "Code Wins" sovereignty:
88
+ * - Model, Tools, Instructions come from the Agent class (code)
89
+ * - Message history comes from the database
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * // Create a new session
94
+ * const session = await createAgentSession(prisma, llm, SupportAgent, {
95
+ * metadata: { userId: "123" }
96
+ * });
97
+ *
98
+ * // Resume a session
99
+ * const session = await loadAgentSession(prisma, llm, SupportAgent, "sess_abc");
100
+ *
101
+ * // Agent behavior is always defined in code
102
+ * const result = await session.ask("Hello");
103
+ * ```
104
+ */
105
+ export class AgentSession<T extends Agent = Agent> {
106
+ private currentMessageId: string | null = null;
107
+ private tableNames: Required<TableNames>;
108
+ private debug: boolean;
109
+
110
+ constructor(
111
+ private prisma: any,
112
+ private llm: NodeLLMCore,
113
+ private AgentClass: AgentClass<T>,
114
+ private record: AgentSessionRecord,
115
+ tableNames?: TableNames,
116
+ private agent: T = new AgentClass({
117
+ llm
118
+ }),
119
+ debug: boolean = false
120
+ ) {
121
+ this.debug = debug;
122
+ this.tableNames = {
123
+ agentSession: tableNames?.agentSession || "llmAgentSession",
124
+ chat: tableNames?.chat || "llmChat",
125
+ message: tableNames?.message || "llmMessage",
126
+ toolCall: tableNames?.toolCall || "llmToolCall",
127
+ request: tableNames?.request || "llmRequest"
128
+ };
129
+ }
130
+
131
+ private log(...args: any[]) {
132
+ if (this.debug) {
133
+ console.log(`[@node-llm/orm]`, ...args);
134
+ }
135
+ }
136
+
137
+ /** Agent instance (for direct access if needed) */
138
+ get instance(): T {
139
+ return this.agent;
140
+ }
141
+
142
+ /** Session ID for persistence */
143
+ get id(): string {
144
+ return this.record.id;
145
+ }
146
+
147
+ /** Underlying chat ID */
148
+ get chatId(): string {
149
+ return this.record.chatId;
150
+ }
151
+
152
+ /** Session metadata */
153
+ get metadata(): Record<string, unknown> | null | undefined {
154
+ return this.record.metadata;
155
+ }
156
+
157
+ /** Agent class name */
158
+ get agentClass(): string {
159
+ return this.record.agentClass;
160
+ }
161
+
162
+ /** Model ID used by the agent */
163
+ get modelId(): string {
164
+ return this.agent.modelId;
165
+ }
166
+
167
+ /** Cumulative usage for this session (from agent memory) */
168
+ get totalUsage(): Usage {
169
+ return this.agent.totalUsage;
170
+ }
171
+
172
+ /** Current in-memory message history */
173
+ get history(): readonly Message[] {
174
+ return this.agent.history;
175
+ }
176
+
177
+ /**
178
+ * Helper to get a typed Prisma model by its dynamic name.
179
+ */
180
+ private getModel<R = Record<string, unknown>>(name: string): PrismaModel<R> {
181
+ return getTable(this.prisma, name) as unknown as PrismaModel<R>;
182
+ }
183
+
184
+ /**
185
+ * Send a message and persist the conversation.
186
+ */
187
+ async ask(input: string, options: AskOptions = {}): Promise<MessageRecord> {
188
+ const model = this.getModel<MessageRecord>(this.tableNames.message);
189
+
190
+ // Persist user message
191
+ await model.create({
192
+ data: { chatId: this.chatId, role: "user", content: input }
193
+ });
194
+
195
+ // Create placeholder for assistant message
196
+ const assistantMessage = await model.create({
197
+ data: { chatId: this.chatId, role: "assistant", content: null }
198
+ });
199
+
200
+ this.currentMessageId = assistantMessage.id;
201
+
202
+ try {
203
+ // Get response from agent (uses code-defined config + injected history)
204
+ const response = await this.agent.ask(input, options);
205
+
206
+ // Update assistant message with response
207
+ return await model.update({
208
+ where: { id: assistantMessage.id },
209
+ data: {
210
+ content: response.content,
211
+ contentRaw: JSON.stringify(response.meta),
212
+ inputTokens: response.usage?.input_tokens || 0,
213
+ outputTokens: response.usage?.output_tokens || 0,
214
+ thinkingText: response.thinking?.text || null,
215
+ thinkingSignature: response.thinking?.signature || null,
216
+ thinkingTokens: response.thinking?.tokens || null,
217
+ modelId: response.model || null,
218
+ provider: response.provider || null
219
+ }
220
+ });
221
+ } catch (error) {
222
+ // Clean up placeholder on error
223
+ await model.delete({ where: { id: assistantMessage.id } });
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Stream a response and persist the conversation.
230
+ */
231
+ async *askStream(
232
+ input: string,
233
+ options: AskOptions = {}
234
+ ): AsyncGenerator<ChatChunk, MessageRecord, undefined> {
235
+ const model = this.getModel<MessageRecord>(this.tableNames.message);
236
+
237
+ // Persist user message
238
+ await model.create({
239
+ data: { chatId: this.chatId, role: "user", content: input }
240
+ });
241
+
242
+ // Create placeholder for assistant message
243
+ const assistantMessage = await model.create({
244
+ data: { chatId: this.chatId, role: "assistant", content: null }
245
+ });
246
+
247
+ this.currentMessageId = assistantMessage.id;
248
+
249
+ try {
250
+ const stream = this.agent.stream(input, options);
251
+
252
+ let fullContent = "";
253
+ let lastChunk: ChatChunk | null = null;
254
+
255
+ for await (const chunk of stream) {
256
+ fullContent += chunk.content;
257
+ lastChunk = chunk;
258
+ yield chunk;
259
+ }
260
+
261
+ // Final update with accumulated result
262
+ return await model.update({
263
+ where: { id: assistantMessage.id },
264
+ data: {
265
+ content: fullContent,
266
+ inputTokens: lastChunk?.usage?.input_tokens || 0,
267
+ outputTokens: lastChunk?.usage?.output_tokens || 0,
268
+ thinkingText: lastChunk?.thinking?.text || null,
269
+ thinkingSignature: lastChunk?.thinking?.signature || null,
270
+ thinkingTokens: lastChunk?.thinking?.tokens || null,
271
+ modelId: (lastChunk?.metadata?.model as string) || null,
272
+ provider: (lastChunk?.metadata?.provider as string) || null
273
+ }
274
+ });
275
+ } catch (error) {
276
+ await model.delete({ where: { id: assistantMessage.id } });
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Returns the current full message history for this session.
283
+ */
284
+ async messages(): Promise<MessageRecord[]> {
285
+ const model = this.getModel<MessageRecord>(this.tableNames.message);
286
+ return await model.findMany({
287
+ where: { chatId: this.chatId },
288
+ orderBy: { createdAt: "asc" }
289
+ });
290
+ }
291
+
292
+ /**
293
+ * Delete the entire session and its history.
294
+ */
295
+ async delete(): Promise<void> {
296
+ const chatTable = this.getModel(this.tableNames.chat);
297
+ await chatTable.delete({ where: { id: this.chatId } });
298
+ // AgentSession record is deleted via Cascade from LlmChat
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Options for creating a new agent session.
304
+ */
305
+ export interface CreateAgentSessionOptions {
306
+ metadata?: Record<string, unknown>;
307
+ tableNames?: TableNames;
308
+ debug?: boolean;
309
+ }
310
+
311
+ /**
312
+ * Creates a new agent session and its persistent chat record.
313
+ */
314
+ export async function createAgentSession<T extends Agent>(
315
+ prisma: any,
316
+ llm: NodeLLMCore,
317
+ AgentClass: AgentClass<T>,
318
+ options: CreateAgentSessionOptions = {}
319
+ ): Promise<AgentSession<T>> {
320
+ const tableNames = {
321
+ agentSession: options.tableNames?.agentSession || "llmAgentSession",
322
+ chat: options.tableNames?.chat || "llmChat",
323
+ message: options.tableNames?.message || "llmMessage"
324
+ };
325
+
326
+ if (options.debug) {
327
+ console.log(`[@node-llm/orm] createAgentSession: agentClass=${AgentClass.name}`);
328
+ }
329
+
330
+ // 1. Create underlying LlmChat record
331
+ const chatTable = getTable(prisma, tableNames.chat);
332
+ const chatRecord = (await chatTable.create({
333
+ data: {
334
+ model: AgentClass.model || null,
335
+ provider: null,
336
+ instructions: AgentClass.instructions || null,
337
+ metadata: null // Runtime metadata goes in Chat, session context in AgentSession
338
+ }
339
+ })) as unknown as { id: string };
340
+
341
+ // 2. Create AgentSession record
342
+ const sessionTable = getTable(prisma, tableNames.agentSession);
343
+ const sessionRecord = (await sessionTable.create({
344
+ data: {
345
+ agentClass: AgentClass.name,
346
+ chatId: chatRecord.id,
347
+ metadata: options.metadata || null
348
+ }
349
+ })) as unknown as AgentSessionRecord;
350
+
351
+ return new AgentSession(
352
+ prisma,
353
+ llm,
354
+ AgentClass,
355
+ sessionRecord,
356
+ options.tableNames,
357
+ undefined,
358
+ options.debug
359
+ );
360
+ }
361
+
362
+ /**
363
+ * Options for loading an existing agent session.
364
+ */
365
+ export interface LoadAgentSessionOptions {
366
+ tableNames?: TableNames;
367
+ debug?: boolean;
368
+ }
369
+
370
+ /**
371
+ * Loads an existing agent session and re-instantiates the agent with history.
372
+ */
373
+ export async function loadAgentSession<T extends Agent>(
374
+ prisma: any,
375
+ llm: NodeLLMCore,
376
+ AgentClass: AgentClass<T>,
377
+ sessionId: string,
378
+ options: LoadAgentSessionOptions = {}
379
+ ): Promise<AgentSession<T> | null> {
380
+ const tableNames = {
381
+ agentSession: options.tableNames?.agentSession || "llmAgentSession",
382
+ chat: options.tableNames?.chat || "llmChat",
383
+ message: options.tableNames?.message || "llmMessage"
384
+ };
385
+
386
+ if (options.debug) {
387
+ console.log(`[@node-llm/orm] loadAgentSession: id=${sessionId}`);
388
+ }
389
+
390
+ // 1. Find session record
391
+ const sessionTable = getTable(prisma, tableNames.agentSession);
392
+ const sessionRecord = (await sessionTable.findUnique({
393
+ where: { id: sessionId }
394
+ })) as unknown as AgentSessionRecord | null;
395
+
396
+ if (!sessionRecord) {
397
+ return null;
398
+ }
399
+
400
+ // 1.5. Validate Agent Class (Code Wins Sovereignty)
401
+ if (sessionRecord.agentClass !== AgentClass.name) {
402
+ throw new Error(
403
+ `Agent class mismatch: Session "${sessionId}" was created for "${sessionRecord.agentClass}", but is being loaded with "${AgentClass.name}".`
404
+ );
405
+ }
406
+
407
+ // 2. Load message history
408
+ const messageTable = getTable(prisma, tableNames.message);
409
+ const messages = (await messageTable.findMany({
410
+ where: { chatId: sessionRecord.chatId },
411
+ orderBy: { createdAt: "asc" }
412
+ })) as unknown as MessageRecord[];
413
+
414
+ // 3. Convert DB messages to NodeLLM Message format
415
+ const history: Message[] = messages.map((m) => ({
416
+ role: m.role as "user" | "assistant" | "system",
417
+ content: m.content || ""
418
+ }));
419
+
420
+ // 4. Instantiate agent with injected history and LLM
421
+ // "Code Wins" - model, tools, instructions come from AgentClass
422
+ const agent = new AgentClass({
423
+ llm,
424
+ messages: history
425
+ }) as T;
426
+
427
+ return new AgentSession(
428
+ prisma,
429
+ llm,
430
+ AgentClass,
431
+ sessionRecord,
432
+ options.tableNames,
433
+ agent,
434
+ options.debug
435
+ );
436
+ }
437
+
438
+ /**
439
+ * Dynamic helper to access Prisma models by name.
440
+ * Handles both case-sensitive and case-insensitive lookups for flexibility.
441
+ */
442
+ function getTable(prisma: GenericPrismaClient, tableName: string): PrismaModel {
443
+ const p = prisma as unknown as Record<string, PrismaModel>;
444
+
445
+ // 1. Direct match
446
+ const table = p[tableName];
447
+ if (table) return table;
448
+
449
+ // 2. Case-insensitive match
450
+ const keys = Object.keys(prisma).filter((k) => !k.startsWith("$") && !k.startsWith("_"));
451
+ const match = keys.find((k) => k.toLowerCase() === tableName.toLowerCase());
452
+
453
+ if (match && p[match]) return p[match];
454
+
455
+ throw new Error(
456
+ `[@node-llm/orm] Prisma table "${tableName}" not found. Available tables: ${keys.join(", ")}`
457
+ );
458
+ }