@rudderjs/ai 1.11.2 → 1.12.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,85 @@
1
+ import type { AiMessage, ContinuationValidator, ValidateContinuationOptions } from './types.js';
2
+ export type { ContinuationValidator, ValidateContinuationOptions } from './types.js';
3
+ /**
4
+ * Continuation validation — defends the auto-persist / continuation path
5
+ * against a client that resubmits a forged conversation.
6
+ *
7
+ * `runWithPersistence` (and the explicit `forUser`/`continue` form) trusts
8
+ * the caller's incoming history. A continuation request after a client-tool
9
+ * or approval round-trip carries the prior messages back from the browser,
10
+ * which means a malicious caller can:
11
+ *
12
+ * 1. **Rewrite history / continue someone else's thread (IDOR)** — send
13
+ * messages that don't match what the server persisted for this thread.
14
+ * 2. **Forge a tool result** — append a `tool` message answering a tool
15
+ * call that the server never issued (smuggling attacker-chosen data in
16
+ * as if a tool produced it).
17
+ * 3. **Forge an approval** — claim `approvedToolCallIds` for a tool call
18
+ * that isn't actually pending approval.
19
+ *
20
+ * {@link validateContinuation} runs all three checks against the trusted
21
+ * `persisted` history and returns a verdict. {@link assertValidContinuation}
22
+ * throws {@link ContinuationValidationError} on failure, and
23
+ * {@link defaultContinuationValidator} adapts it to the
24
+ * {@link ContinuationValidator} hook shape consumed by `AgentPromptOptions`.
25
+ */
26
+ export type ContinuationRejectionCode = 'not-a-prefix' | 'forged-tool-result' | 'forged-approval';
27
+ export interface ContinuationValidationResult {
28
+ /** `true` when the incoming continuation is a legitimate extension of `persisted`. */
29
+ ok: boolean;
30
+ /** Machine-readable reason, present only when `ok` is `false`. */
31
+ code?: ContinuationRejectionCode;
32
+ /** Human-readable explanation, present only when `ok` is `false`. */
33
+ reason?: string;
34
+ /**
35
+ * Index of the offending message — into `incoming` for `not-a-prefix` and
36
+ * `forged-tool-result`. Absent for `forged-approval` (the offending id is
37
+ * named in `reason` instead).
38
+ */
39
+ index?: number;
40
+ }
41
+ /**
42
+ * Validate that an `incoming` continuation is a legitimate extension of the
43
+ * server-persisted `persisted` history. Pure and synchronous — safe to call
44
+ * from any runtime. Returns a verdict; never throws.
45
+ *
46
+ * Checks, in order:
47
+ *
48
+ * - **Prefix equality.** Every message the two share by position must match
49
+ * (role, content, `toolCallId`, and any assistant `toolCalls`). Comparison
50
+ * is order-insensitive for nested objects, so a tool-call `arguments` map
51
+ * reordered across a serialization boundary still matches. A genuine
52
+ * mismatch means the caller rewrote history or is replaying a different
53
+ * thread (IDOR) → `not-a-prefix`.
54
+ * - **Tool-result forgery.** Every `tool` message in `incoming` must answer
55
+ * a tool call actually requested by some assistant message (in either
56
+ * `persisted` or `incoming`). A `tool` message with no matching request is
57
+ * smuggled data → `forged-tool-result`.
58
+ * - **Approval forgery.** Every id in `opts.approvedToolCallIds` /
59
+ * `opts.rejectedToolCallIds` must reference a real requested tool call →
60
+ * `forged-approval`.
61
+ */
62
+ export declare function validateContinuation(persisted: readonly AiMessage[], incoming: readonly AiMessage[], opts?: ValidateContinuationOptions): ContinuationValidationResult;
63
+ /** Thrown by {@link assertValidContinuation} when validation fails. */
64
+ export declare class ContinuationValidationError extends Error {
65
+ readonly code: ContinuationRejectionCode;
66
+ readonly index: number | undefined;
67
+ constructor(result: ContinuationValidationResult);
68
+ }
69
+ /**
70
+ * {@link validateContinuation} that throws {@link ContinuationValidationError}
71
+ * instead of returning a verdict. Use directly, or via
72
+ * {@link defaultContinuationValidator} as a `validate` hook.
73
+ */
74
+ export declare function assertValidContinuation(persisted: readonly AiMessage[], incoming: readonly AiMessage[], opts?: ValidateContinuationOptions): void;
75
+ /**
76
+ * A ready-made {@link ContinuationValidator} backed by
77
+ * {@link assertValidContinuation}. Drop into `AgentPromptOptions.validate`
78
+ * for the default prefix + tool-result-forgery + approval-forgery gate:
79
+ *
80
+ * ```ts
81
+ * agent.continue(id).prompt(input, { validate: defaultContinuationValidator() })
82
+ * ```
83
+ */
84
+ export declare function defaultContinuationValidator(): ContinuationValidator;
85
+ //# sourceMappingURL=continuation-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"continuation-validation.d.ts","sourceRoot":"","sources":["../src/continuation-validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,qBAAqB,EAAY,2BAA2B,EAAE,MAAM,YAAY,CAAA;AAEzG,YAAY,EAAE,qBAAqB,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAA;AAEpF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,MAAM,MAAM,yBAAyB,GACjC,cAAc,GACd,oBAAoB,GACpB,iBAAiB,CAAA;AAErB,MAAM,WAAW,4BAA4B;IAC3C,sFAAsF;IACtF,EAAE,EAAE,OAAO,CAAA;IACX,kEAAkE;IAClE,IAAI,CAAC,EAAE,yBAAyB,CAAA;IAChC,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAmED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,SAAS,SAAS,EAAE,EAC/B,QAAQ,EAAE,SAAS,SAAS,EAAE,EAC9B,IAAI,GAAE,2BAAgC,GACrC,4BAA4B,CA2C9B;AAED,uEAAuE;AACvE,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAA;IACxC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;gBACtB,MAAM,EAAE,4BAA4B;CAMjD;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,SAAS,SAAS,EAAE,EAC/B,QAAQ,EAAE,SAAS,SAAS,EAAE,EAC9B,IAAI,GAAE,2BAAgC,GACrC,IAAI,CAGN;AAED;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,IAAI,qBAAqB,CAEpE"}
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Recursively sort object keys (and drop `undefined`) so two semantically
3
+ * equal values compare equal regardless of key insertion order. Without this,
4
+ * a tool-call `arguments` object reordered across a serialization boundary
5
+ * (e.g. loaded back from a Postgres `jsonb` column, which does not preserve
6
+ * key order, or rebuilt by the client) would be read as a forgery.
7
+ */
8
+ function canonicalize(value) {
9
+ if (value === null || typeof value !== 'object')
10
+ return value;
11
+ if (Array.isArray(value))
12
+ return value.map(canonicalize);
13
+ const out = {};
14
+ for (const key of Object.keys(value).sort()) {
15
+ const v = value[key];
16
+ if (v === undefined)
17
+ continue;
18
+ out[key] = canonicalize(v);
19
+ }
20
+ return out;
21
+ }
22
+ /** Order-insensitive JSON of a value. */
23
+ function canonicalJson(value) {
24
+ return JSON.stringify(canonicalize(value));
25
+ }
26
+ /** Order-insensitive string form of message content. */
27
+ function contentString(content) {
28
+ return typeof content === 'string' ? content : canonicalJson(content);
29
+ }
30
+ /**
31
+ * Compare two messages field by field, order-insensitively. Returns a
32
+ * human-readable reason naming the first diverging field, or `null` when the
33
+ * messages are equivalent. Used by the prefix check so a rejection points at
34
+ * exactly what diverged.
35
+ */
36
+ function messageDiffReason(a, b) {
37
+ if (a.role !== b.role)
38
+ return `role: persisted "${a.role}" vs incoming "${b.role}"`;
39
+ if (contentString(a.content) !== contentString(b.content))
40
+ return 'content differs';
41
+ if ((a.toolCallId ?? null) !== (b.toolCallId ?? null)) {
42
+ return `toolCallId: persisted "${a.toolCallId ?? 'null'}" vs incoming "${b.toolCallId ?? 'null'}"`;
43
+ }
44
+ const aCalls = a.toolCalls ?? [];
45
+ const bCalls = b.toolCalls ?? [];
46
+ if (aCalls.length !== bCalls.length)
47
+ return `toolCalls length: persisted ${aCalls.length} vs incoming ${bCalls.length}`;
48
+ for (let i = 0; i < aCalls.length; i++) {
49
+ const ac = aCalls[i];
50
+ const bc = bCalls[i];
51
+ if (ac.id !== bc.id)
52
+ return `toolCalls[${i}].id: persisted "${ac.id}" vs incoming "${bc.id}"`;
53
+ if (ac.name !== bc.name)
54
+ return `toolCalls[${i}].name: persisted "${ac.name}" vs incoming "${bc.name}"`;
55
+ if (canonicalJson(ac.arguments) !== canonicalJson(bc.arguments))
56
+ return `toolCalls[${i}].arguments differ (${ac.name})`;
57
+ }
58
+ return null;
59
+ }
60
+ /** Collect every tool-call id the model requested across the given messages. */
61
+ function requestedToolCallIds(messages) {
62
+ const ids = new Set();
63
+ for (const m of messages) {
64
+ if (m.role === 'assistant' && m.toolCalls) {
65
+ for (const c of m.toolCalls)
66
+ ids.add(c.id);
67
+ }
68
+ }
69
+ return ids;
70
+ }
71
+ /**
72
+ * Validate that an `incoming` continuation is a legitimate extension of the
73
+ * server-persisted `persisted` history. Pure and synchronous — safe to call
74
+ * from any runtime. Returns a verdict; never throws.
75
+ *
76
+ * Checks, in order:
77
+ *
78
+ * - **Prefix equality.** Every message the two share by position must match
79
+ * (role, content, `toolCallId`, and any assistant `toolCalls`). Comparison
80
+ * is order-insensitive for nested objects, so a tool-call `arguments` map
81
+ * reordered across a serialization boundary still matches. A genuine
82
+ * mismatch means the caller rewrote history or is replaying a different
83
+ * thread (IDOR) → `not-a-prefix`.
84
+ * - **Tool-result forgery.** Every `tool` message in `incoming` must answer
85
+ * a tool call actually requested by some assistant message (in either
86
+ * `persisted` or `incoming`). A `tool` message with no matching request is
87
+ * smuggled data → `forged-tool-result`.
88
+ * - **Approval forgery.** Every id in `opts.approvedToolCallIds` /
89
+ * `opts.rejectedToolCallIds` must reference a real requested tool call →
90
+ * `forged-approval`.
91
+ */
92
+ export function validateContinuation(persisted, incoming, opts = {}) {
93
+ // 1. Prefix equality over the shared region. Comparison is order-insensitive
94
+ // for nested objects (tool-call arguments, structured content) so a
95
+ // legitimately reordered argument map is not mistaken for a forgery.
96
+ const overlap = Math.min(persisted.length, incoming.length);
97
+ for (let i = 0; i < overlap; i++) {
98
+ const diff = messageDiffReason(persisted[i], incoming[i]);
99
+ if (diff) {
100
+ return {
101
+ ok: false,
102
+ code: 'not-a-prefix',
103
+ index: i,
104
+ reason: `incoming message at index ${i} diverges from the persisted history (${diff})`,
105
+ };
106
+ }
107
+ }
108
+ // 2. Tool-result forgery — a tool message must answer a requested call.
109
+ const requested = requestedToolCallIds([...persisted, ...incoming]);
110
+ for (let i = 0; i < incoming.length; i++) {
111
+ const m = incoming[i];
112
+ if (m.role === 'tool' && (!m.toolCallId || !requested.has(m.toolCallId))) {
113
+ return {
114
+ ok: false,
115
+ code: 'forged-tool-result',
116
+ index: i,
117
+ reason: `tool message at index ${i} references tool call "${m.toolCallId ?? '<missing>'}" that was never requested`,
118
+ };
119
+ }
120
+ }
121
+ // 3. Approval forgery — approved/rejected ids must be real requested calls.
122
+ for (const id of [...(opts.approvedToolCallIds ?? []), ...(opts.rejectedToolCallIds ?? [])]) {
123
+ if (!requested.has(id)) {
124
+ return {
125
+ ok: false,
126
+ code: 'forged-approval',
127
+ reason: `tool call "${id}" was approved or rejected but was never requested`,
128
+ };
129
+ }
130
+ }
131
+ return { ok: true };
132
+ }
133
+ /** Thrown by {@link assertValidContinuation} when validation fails. */
134
+ export class ContinuationValidationError extends Error {
135
+ code;
136
+ index;
137
+ constructor(result) {
138
+ super(`[RudderJS AI] Rejected continuation: ${result.reason ?? result.code ?? 'invalid'}`);
139
+ this.name = 'ContinuationValidationError';
140
+ this.code = result.code ?? 'not-a-prefix';
141
+ this.index = result.index;
142
+ }
143
+ }
144
+ /**
145
+ * {@link validateContinuation} that throws {@link ContinuationValidationError}
146
+ * instead of returning a verdict. Use directly, or via
147
+ * {@link defaultContinuationValidator} as a `validate` hook.
148
+ */
149
+ export function assertValidContinuation(persisted, incoming, opts = {}) {
150
+ const result = validateContinuation(persisted, incoming, opts);
151
+ if (!result.ok)
152
+ throw new ContinuationValidationError(result);
153
+ }
154
+ /**
155
+ * A ready-made {@link ContinuationValidator} backed by
156
+ * {@link assertValidContinuation}. Drop into `AgentPromptOptions.validate`
157
+ * for the default prefix + tool-result-forgery + approval-forgery gate:
158
+ *
159
+ * ```ts
160
+ * agent.continue(id).prompt(input, { validate: defaultContinuationValidator() })
161
+ * ```
162
+ */
163
+ export function defaultContinuationValidator() {
164
+ return (persisted, incoming, opts) => assertValidContinuation(persisted, incoming, opts);
165
+ }
166
+ //# sourceMappingURL=continuation-validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"continuation-validation.js","sourceRoot":"","sources":["../src/continuation-validation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `@rudderjs/ai/conversation-orm` - ORM-backed {@link ConversationStore}.
3
+ *
4
+ * Production-grade replacement for `MemoryConversationStore` (which is
5
+ * single-process, in-memory, and loses every thread on restart). Persists
6
+ * conversation threads and their messages via the registered `@rudderjs/orm`
7
+ * adapter - works across web processes, queue workers, and horizontally
8
+ * scaled deployments. Mirrors the `@rudderjs/ai/memory-orm` /
9
+ * `@rudderjs/ai/budget-orm` pattern.
10
+ *
11
+ * Wire it as the conversation store:
12
+ *
13
+ * ```ts
14
+ * import { setConversationStore } from '@rudderjs/ai'
15
+ * import { OrmConversationStore } from '@rudderjs/ai/conversation-orm'
16
+ *
17
+ * setConversationStore(new OrmConversationStore())
18
+ * ```
19
+ *
20
+ * The schema lives at {@link conversationOrmPrismaSchema} - copy it into your
21
+ * Prisma schema (or a new `prisma/schema/<file>.prisma` if you use the
22
+ * multi-file setup). On the native engine, add an equivalent migration; on
23
+ * Drizzle, define matching tables and register them via `tables: { ... }`.
24
+ *
25
+ * # Adapter coverage
26
+ *
27
+ * - Prisma - works out of the box; copy {@link conversationOrmPrismaSchema}.
28
+ * - Native - add a migration with the same columns.
29
+ * - Drizzle - define the two tables and register them on the `drizzle()`
30
+ * config.
31
+ *
32
+ * # Ordering & concurrency
33
+ *
34
+ * Messages carry a monotonic per-thread `position` so `load()` returns them
35
+ * in append order regardless of timestamp granularity. `append()` reads the
36
+ * current max position and assigns the next slots; like
37
+ * `OrmBudgetStorage.checkAndDebit`, the read-then-write is not isolated, so
38
+ * two concurrent appends to the SAME thread could collide on a position.
39
+ * Conversation threads are single-writer in practice (one user, one turn at
40
+ * a time), so this is a non-issue for typical apps. File an issue if you hit
41
+ * it; strict ordering needs a serializable transaction or a DB sequence.
42
+ */
43
+ import { Model } from '@rudderjs/orm';
44
+ import type { AiMessage, ConversationStore, ConversationStoreListEntry, ConversationStoreMeta } from '../types.js';
45
+ /**
46
+ * The thread row backing {@link OrmConversationStore}. Exposed so apps that
47
+ * want their own queries (admin views, analytics) can use
48
+ * `AiConversationRecord.where(...).get()` directly.
49
+ *
50
+ * `userId` / `agent` mirror {@link ConversationStoreMeta} - `userId` scopes
51
+ * `list()`, `agent` carries the thread-segregation key the auto-persist
52
+ * machinery uses to keep one user's threads per agent class apart.
53
+ */
54
+ export declare class AiConversationRecord extends Model {
55
+ static table: string;
56
+ static fillable: string[];
57
+ id: string;
58
+ title: string;
59
+ userId: string | null;
60
+ agent: string | null;
61
+ createdAt: Date;
62
+ updatedAt: Date | null;
63
+ }
64
+ /**
65
+ * One message row in a thread. `content` and `toolCalls` are JSON-encoded
66
+ * strings (so a `string` content and a `ContentPart[]` content both
67
+ * round-trip through a portable `text` column); `position` orders them.
68
+ */
69
+ export declare class AiConversationMessageRecord extends Model {
70
+ static table: string;
71
+ static fillable: string[];
72
+ id: string;
73
+ conversationId: string;
74
+ position: number;
75
+ role: string;
76
+ /** JSON-encoded `string | ContentPart[]`. */
77
+ content: string;
78
+ toolCallId: string | null;
79
+ /** JSON-encoded `ToolCall[]` or null. */
80
+ toolCalls: string | null;
81
+ createdAt: Date;
82
+ }
83
+ /**
84
+ * {@link ConversationStore} implementation that persists rows to the
85
+ * registered ORM adapter. Designed for production use - the in-process
86
+ * `MemoryConversationStore` is for tests and dev.
87
+ */
88
+ export declare class OrmConversationStore implements ConversationStore {
89
+ create(title?: string, meta?: ConversationStoreMeta): Promise<string>;
90
+ load(conversationId: string): Promise<AiMessage[]>;
91
+ append(conversationId: string, messages: AiMessage[]): Promise<void>;
92
+ setTitle(conversationId: string, title: string): Promise<void>;
93
+ list(userId?: string): Promise<ConversationStoreListEntry[]>;
94
+ delete(conversationId: string): Promise<void>;
95
+ /** Throw the same not-found error shape as `MemoryConversationStore`. */
96
+ private requireThread;
97
+ /** Next monotonic position for the thread (0 when empty). */
98
+ private nextPosition;
99
+ }
100
+ /** Convenience factory mirroring `ormBudgetStorage()` / `OrmUserMemory`. */
101
+ export declare function ormConversationStore(): OrmConversationStore;
102
+ /**
103
+ * Reference Prisma schema for `OrmConversationStore`. Copy into your
104
+ * `prisma/schema/<file>.prisma`. SQLite stores the `text` content as TEXT;
105
+ * Postgres as `text`. The `@@index` keeps `list()` (by user) and `load()`
106
+ * (by thread, ordered) cheap.
107
+ */
108
+ export declare const conversationOrmPrismaSchema = "model AiConversation {\n id String @id @default(cuid())\n title String\n userId String?\n /// Thread-segregation key - the agent class name by default\n agent String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n}\n\nmodel AiConversationMessage {\n id String @id @default(cuid())\n conversationId String\n /// Monotonic per-thread ordering\n position Int\n role String\n /// JSON-encoded `string | ContentPart[]`\n content String\n toolCallId String?\n /// JSON-encoded `ToolCall[]` or null\n toolCalls String?\n createdAt DateTime @default(now())\n\n @@index([conversationId, position])\n}\n";
109
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/conversation-orm/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAA;AACrC,OAAO,KAAK,EACV,SAAS,EACT,iBAAiB,EACjB,0BAA0B,EAC1B,qBAAqB,EAEtB,MAAM,aAAa,CAAA;AAIpB;;;;;;;;GAQG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,OAAgB,KAAK,SAAsB;IAC3C,OAAgB,QAAQ,WAA4C;IAE5D,EAAE,EAAS,MAAM,CAAA;IACjB,KAAK,EAAM,MAAM,CAAA;IACjB,MAAM,EAAK,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAM,MAAM,GAAG,IAAI,CAAA;IACxB,SAAS,EAAE,IAAI,CAAA;IACf,SAAS,EAAE,IAAI,GAAG,IAAI,CAAA;CAC/B;AAED;;;;GAIG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,OAAgB,KAAK,SAA6B;IAClD,OAAgB,QAAQ,WAA+E;IAE/F,EAAE,EAAc,MAAM,CAAA;IACtB,cAAc,EAAE,MAAM,CAAA;IACtB,QAAQ,EAAQ,MAAM,CAAA;IACtB,IAAI,EAAY,MAAM,CAAA;IAC9B,6CAA6C;IACrC,OAAO,EAAS,MAAM,CAAA;IACtB,UAAU,EAAM,MAAM,GAAG,IAAI,CAAA;IACrC,yCAAyC;IACjC,SAAS,EAAO,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,EAAO,IAAI,CAAA;CAC7B;AAID;;;;GAIG;AACH,qBAAa,oBAAqB,YAAW,iBAAiB;IACtD,MAAM,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC;IASrE,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IASlD,MAAM,CAAC,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAapE,QAAQ,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO9D,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,EAAE,CAAC;IAO5D,MAAM,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKnD,yEAAyE;YAC3D,aAAa;IAK3B,6DAA6D;YAC/C,YAAY;CAO3B;AAED,4EAA4E;AAC5E,wBAAgB,oBAAoB,IAAI,oBAAoB,CAE3D;AAID;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,+tBA2BvC,CAAA"}
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `@rudderjs/ai/conversation-orm` - ORM-backed {@link ConversationStore}.
3
+ *
4
+ * Production-grade replacement for `MemoryConversationStore` (which is
5
+ * single-process, in-memory, and loses every thread on restart). Persists
6
+ * conversation threads and their messages via the registered `@rudderjs/orm`
7
+ * adapter - works across web processes, queue workers, and horizontally
8
+ * scaled deployments. Mirrors the `@rudderjs/ai/memory-orm` /
9
+ * `@rudderjs/ai/budget-orm` pattern.
10
+ *
11
+ * Wire it as the conversation store:
12
+ *
13
+ * ```ts
14
+ * import { setConversationStore } from '@rudderjs/ai'
15
+ * import { OrmConversationStore } from '@rudderjs/ai/conversation-orm'
16
+ *
17
+ * setConversationStore(new OrmConversationStore())
18
+ * ```
19
+ *
20
+ * The schema lives at {@link conversationOrmPrismaSchema} - copy it into your
21
+ * Prisma schema (or a new `prisma/schema/<file>.prisma` if you use the
22
+ * multi-file setup). On the native engine, add an equivalent migration; on
23
+ * Drizzle, define matching tables and register them via `tables: { ... }`.
24
+ *
25
+ * # Adapter coverage
26
+ *
27
+ * - Prisma - works out of the box; copy {@link conversationOrmPrismaSchema}.
28
+ * - Native - add a migration with the same columns.
29
+ * - Drizzle - define the two tables and register them on the `drizzle()`
30
+ * config.
31
+ *
32
+ * # Ordering & concurrency
33
+ *
34
+ * Messages carry a monotonic per-thread `position` so `load()` returns them
35
+ * in append order regardless of timestamp granularity. `append()` reads the
36
+ * current max position and assigns the next slots; like
37
+ * `OrmBudgetStorage.checkAndDebit`, the read-then-write is not isolated, so
38
+ * two concurrent appends to the SAME thread could collide on a position.
39
+ * Conversation threads are single-writer in practice (one user, one turn at
40
+ * a time), so this is a non-issue for typical apps. File an issue if you hit
41
+ * it; strict ordering needs a serializable transaction or a DB sequence.
42
+ */
43
+ import { Model } from '@rudderjs/orm';
44
+ // ─── ORM Models ───────────────────────────────────────────
45
+ /**
46
+ * The thread row backing {@link OrmConversationStore}. Exposed so apps that
47
+ * want their own queries (admin views, analytics) can use
48
+ * `AiConversationRecord.where(...).get()` directly.
49
+ *
50
+ * `userId` / `agent` mirror {@link ConversationStoreMeta} - `userId` scopes
51
+ * `list()`, `agent` carries the thread-segregation key the auto-persist
52
+ * machinery uses to keep one user's threads per agent class apart.
53
+ */
54
+ export class AiConversationRecord extends Model {
55
+ static table = 'aiConversation';
56
+ static fillable = ['title', 'userId', 'agent', 'updatedAt'];
57
+ }
58
+ /**
59
+ * One message row in a thread. `content` and `toolCalls` are JSON-encoded
60
+ * strings (so a `string` content and a `ContentPart[]` content both
61
+ * round-trip through a portable `text` column); `position` orders them.
62
+ */
63
+ export class AiConversationMessageRecord extends Model {
64
+ static table = 'aiConversationMessage';
65
+ static fillable = ['conversationId', 'position', 'role', 'content', 'toolCallId', 'toolCalls'];
66
+ }
67
+ // ─── ConversationStore adapter ────────────────────────────
68
+ /**
69
+ * {@link ConversationStore} implementation that persists rows to the
70
+ * registered ORM adapter. Designed for production use - the in-process
71
+ * `MemoryConversationStore` is for tests and dev.
72
+ */
73
+ export class OrmConversationStore {
74
+ async create(title, meta) {
75
+ const data = { title: title ?? 'New conversation' };
76
+ if (meta?.userId !== undefined)
77
+ data['userId'] = meta.userId;
78
+ if (meta?.agent !== undefined)
79
+ data['agent'] = meta.agent;
80
+ const created = await AiConversationRecord.create(data);
81
+ return created.id;
82
+ }
83
+ async load(conversationId) {
84
+ await this.requireThread(conversationId);
85
+ const rows = await AiConversationMessageRecord
86
+ .where('conversationId', conversationId)
87
+ .orderBy('position', 'ASC')
88
+ .get();
89
+ return rows.map(rowToMessage);
90
+ }
91
+ async append(conversationId, messages) {
92
+ await this.requireThread(conversationId);
93
+ if (messages.length === 0)
94
+ return;
95
+ let position = await this.nextPosition(conversationId);
96
+ for (const message of messages) {
97
+ await AiConversationMessageRecord.create(messageToRow(conversationId, position, message));
98
+ position++;
99
+ }
100
+ await AiConversationRecord.where('id', conversationId).updateAll({ updatedAt: new Date() });
101
+ }
102
+ async setTitle(conversationId, title) {
103
+ const updated = await AiConversationRecord
104
+ .where('id', conversationId)
105
+ .updateAll({ title, updatedAt: new Date() });
106
+ if (!updated)
107
+ throw notFound(conversationId);
108
+ }
109
+ async list(userId) {
110
+ let q = AiConversationRecord.query();
111
+ if (userId != null)
112
+ q = q.where('userId', userId);
113
+ const rows = await q.orderBy('updatedAt', 'DESC').get();
114
+ return rows.map(rowToListEntry);
115
+ }
116
+ async delete(conversationId) {
117
+ await AiConversationMessageRecord.where('conversationId', conversationId).deleteAll();
118
+ await AiConversationRecord.where('id', conversationId).deleteAll();
119
+ }
120
+ /** Throw the same not-found error shape as `MemoryConversationStore`. */
121
+ async requireThread(conversationId) {
122
+ const thread = await AiConversationRecord.where('id', conversationId).first();
123
+ if (!thread)
124
+ throw notFound(conversationId);
125
+ }
126
+ /** Next monotonic position for the thread (0 when empty). */
127
+ async nextPosition(conversationId) {
128
+ const last = await AiConversationMessageRecord
129
+ .where('conversationId', conversationId)
130
+ .orderBy('position', 'DESC')
131
+ .first();
132
+ return last ? last.position + 1 : 0;
133
+ }
134
+ }
135
+ /** Convenience factory mirroring `ormBudgetStorage()` / `OrmUserMemory`. */
136
+ export function ormConversationStore() {
137
+ return new OrmConversationStore();
138
+ }
139
+ // ─── Schema reference ─────────────────────────────────────
140
+ /**
141
+ * Reference Prisma schema for `OrmConversationStore`. Copy into your
142
+ * `prisma/schema/<file>.prisma`. SQLite stores the `text` content as TEXT;
143
+ * Postgres as `text`. The `@@index` keeps `list()` (by user) and `load()`
144
+ * (by thread, ordered) cheap.
145
+ */
146
+ export const conversationOrmPrismaSchema = `model AiConversation {
147
+ id String @id @default(cuid())
148
+ title String
149
+ userId String?
150
+ /// Thread-segregation key - the agent class name by default
151
+ agent String?
152
+ createdAt DateTime @default(now())
153
+ updatedAt DateTime @updatedAt
154
+
155
+ @@index([userId])
156
+ }
157
+
158
+ model AiConversationMessage {
159
+ id String @id @default(cuid())
160
+ conversationId String
161
+ /// Monotonic per-thread ordering
162
+ position Int
163
+ role String
164
+ /// JSON-encoded \`string | ContentPart[]\`
165
+ content String
166
+ toolCallId String?
167
+ /// JSON-encoded \`ToolCall[]\` or null
168
+ toolCalls String?
169
+ createdAt DateTime @default(now())
170
+
171
+ @@index([conversationId, position])
172
+ }
173
+ `;
174
+ // ─── Helpers ──────────────────────────────────────────────
175
+ function notFound(conversationId) {
176
+ return new Error(`[RudderJS AI] Conversation "${conversationId}" not found.`);
177
+ }
178
+ function messageToRow(conversationId, position, m) {
179
+ return {
180
+ conversationId,
181
+ position,
182
+ role: m.role,
183
+ content: JSON.stringify(m.content),
184
+ toolCallId: m.toolCallId ?? null,
185
+ toolCalls: m.toolCalls ? JSON.stringify(m.toolCalls) : null,
186
+ };
187
+ }
188
+ function rowToMessage(row) {
189
+ const out = {
190
+ role: row.role,
191
+ content: JSON.parse(row.content),
192
+ };
193
+ if (row.toolCallId != null)
194
+ out.toolCallId = row.toolCallId;
195
+ if (row.toolCalls != null)
196
+ out.toolCalls = JSON.parse(row.toolCalls);
197
+ return out;
198
+ }
199
+ function rowToListEntry(row) {
200
+ const out = {
201
+ id: row.id,
202
+ title: row.title,
203
+ createdAt: row.createdAt,
204
+ };
205
+ if (row.updatedAt != null)
206
+ out.updatedAt = row.updatedAt;
207
+ if (row.agent != null)
208
+ out.agent = row.agent;
209
+ return out;
210
+ }
211
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/conversation-orm/index.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"conversation-persistence.d.ts","sourceRoot":"","sources":["../src/conversation-persistence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,SAAS,EACT,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EAElB,MAAM,YAAY,CAAA;AAEnB;;;;;GAKG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,iBAAiB,GAAG,IAAI,GAAG,SAAS,CAAA;AAEhF;;;;;;;;;;;;;GAaG;AACH,wBAAsB,sBAAsB,CAC1C,SAAS,EAAK,MAAM,KAAK,GAAG,kBAAkB,GAAG,OAAO,CAAC,KAAK,GAAG,kBAAkB,CAAC,EACpF,OAAO,EAAO,sBAAsB,GAAG,SAAS,GAC/C,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAWpC;AA4DD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,SAAS,EAAE,CAUvF;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAY,kBAAkB,EAClC,cAAc,EAAE,MAAM,EACtB,WAAW,EAAK,uBAAuB,EACvC,KAAK,EAAW,MAAM,EACtB,OAAO,EAAS,kBAAkB,GAAG,SAAS,EAC9C,KAAK,EAAW,CAAC,UAAU,EAAE,kBAAkB,GAAG,SAAS,KAAK,OAAO,CAAC,aAAa,CAAC,GACrF,OAAO,CAAC,aAAa,CAAC,CAUxB;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,IAAI,EAAY,kBAAkB,EAClC,cAAc,EAAE,MAAM,EACtB,WAAW,EAAK,uBAAuB,EACvC,KAAK,EAAW,MAAM,EACtB,OAAO,EAAS,kBAAkB,GAAG,SAAS,EAC9C,KAAK,EAAW,CAAC,UAAU,EAAE,kBAAkB,GAAG,SAAS,KAAK,mBAAmB,GAClF,mBAAmB,CAwCrB"}
1
+ {"version":3,"file":"conversation-persistence.d.ts","sourceRoot":"","sources":["../src/conversation-persistence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,SAAS,EACT,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EAElB,MAAM,YAAY,CAAA;AAEnB;;;;;GAKG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,iBAAiB,GAAG,IAAI,GAAG,SAAS,CAAA;AAEhF;;;;;;;;;;;;;GAaG;AACH,wBAAsB,sBAAsB,CAC1C,SAAS,EAAK,MAAM,KAAK,GAAG,kBAAkB,GAAG,OAAO,CAAC,KAAK,GAAG,kBAAkB,CAAC,EACpF,OAAO,EAAO,sBAAsB,GAAG,SAAS,GAC/C,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAWpC;AA0FD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,SAAS,EAAE,CAUvF;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAY,kBAAkB,EAClC,cAAc,EAAE,MAAM,EACtB,WAAW,EAAK,uBAAuB,EACvC,KAAK,EAAW,MAAM,EACtB,OAAO,EAAS,kBAAkB,GAAG,SAAS,EAC9C,KAAK,EAAW,CAAC,UAAU,EAAE,kBAAkB,GAAG,SAAS,KAAK,OAAO,CAAC,aAAa,CAAC,GACrF,OAAO,CAAC,aAAa,CAAC,CAWxB;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,IAAI,EAAY,kBAAkB,EAClC,cAAc,EAAE,MAAM,EACtB,WAAW,EAAK,uBAAuB,EACvC,KAAK,EAAW,MAAM,EACtB,OAAO,EAAS,kBAAkB,GAAG,SAAS,EAC9C,KAAK,EAAW,CAAC,UAAU,EAAE,kBAAkB,GAAG,SAAS,KAAK,mBAAmB,GAClF,mBAAmB,CAyCrB"}
@@ -63,11 +63,33 @@ async function preparePersistence(spec, agentClassName, store, callerHistory) {
63
63
  else {
64
64
  throw new Error('[RudderJS AI] ConversationalSpec must include either `user` or `id`.');
65
65
  }
66
+ // Snapshot the trusted baseline before any limit slice — the validation
67
+ // hook compares the caller's incoming messages against the FULL persisted
68
+ // thread, not the windowed view fed to the model.
69
+ const persisted = loaded;
70
+ let windowed = loaded;
66
71
  if (spec.historyLimit !== undefined && spec.historyLimit > 0) {
67
- loaded = loaded.slice(-spec.historyLimit);
72
+ windowed = loaded.slice(-spec.historyLimit);
68
73
  }
69
- const history = [...loaded, ...(callerHistory ?? [])];
70
- return { spec, store, convId, history };
74
+ const history = [...windowed, ...(callerHistory ?? [])];
75
+ return { spec, store, convId, history, persisted };
76
+ }
77
+ /**
78
+ * Run the caller-supplied `validate` continuation hook, if present. The
79
+ * "incoming" view is the caller's claimed prior conversation — their
80
+ * `options.messages` (full continuation list) when set, else
81
+ * `options.history`. Throws (propagating the rejection) when the hook does.
82
+ */
83
+ async function runValidation(ctx, options) {
84
+ if (!options?.validate)
85
+ return;
86
+ const incoming = options.messages ?? options.history ?? [];
87
+ const opts = {};
88
+ if (options.approvedToolCallIds)
89
+ opts.approvedToolCallIds = options.approvedToolCallIds;
90
+ if (options.rejectedToolCallIds)
91
+ opts.rejectedToolCallIds = options.rejectedToolCallIds;
92
+ await options.validate(ctx.persisted, incoming, opts);
71
93
  }
72
94
  /**
73
95
  * Compose the new turn's messages from the user input + the steps the
@@ -98,6 +120,7 @@ export async function runWithPersistence(spec, agentClassName, storeLookup, inpu
98
120
  if (!store)
99
121
  throw new Error('[RudderJS AI] No ConversationStore registered. Bind one via `setConversationStore()` or the `ai.conversations` DI key.');
100
122
  const ctx = await preparePersistence(spec, agentClassName, store, options?.history);
123
+ await runValidation(ctx, options);
101
124
  const effOptions = { ...options, history: ctx.history };
102
125
  const response = await inner(effOptions);
103
126
  await store.append(ctx.convId, newMessagesFromTurn(input, response));
@@ -123,6 +146,7 @@ export function runWithPersistenceStreaming(spec, agentClassName, storeLookup, i
123
146
  let ctx;
124
147
  try {
125
148
  ctx = await preparePersistence(spec, agentClassName, store, options?.history);
149
+ await runValidation(ctx, options);
126
150
  }
127
151
  catch (err) {
128
152
  rejectResponse(err);