@recombine-ai/platform 0.2.6-test.1 → 0.2.6-test.10

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,295 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PostgresConversationRepository = exports.PostgresAnalytics = void 0;
4
+ exports.migrateAnalyticsDatabase = migrateAnalyticsDatabase;
5
+ exports.createPostgresAnalyticsServices = createPostgresAnalyticsServices;
6
+ const pg_1 = require("pg");
7
+ const analytics_1 = require("./analytics");
8
+ const postgres_analytics_query_1 = require("./postgres-analytics-query");
9
+ class PostgresAnalytics {
10
+ database;
11
+ constructor(database) {
12
+ this.database = database;
13
+ }
14
+ async set(conversationId, agentId, fieldName, value) {
15
+ (0, analytics_1.assertAnalyticsScalar)(value);
16
+ const serialized = JSON.stringify(value);
17
+ const result = await this.database.query(`UPDATE conversations
18
+ SET analytics = jsonb_set(analytics, ARRAY[$3::text], $4::jsonb, true),
19
+ updated_at = now()
20
+ WHERE conversation_id = $1
21
+ AND agent_id = $2`, [conversationId, agentId, fieldName, serialized]);
22
+ assertConversationBelongsToAgent(result.rowCount, conversationId, agentId);
23
+ }
24
+ async clear(conversationId, agentId, fieldName) {
25
+ const result = await this.database.query(`UPDATE conversations
26
+ SET analytics = analytics - $3::text,
27
+ updated_at = now()
28
+ WHERE conversation_id = $1
29
+ AND agent_id = $2`, [conversationId, agentId, fieldName]);
30
+ assertConversationBelongsToAgent(result.rowCount, conversationId, agentId);
31
+ }
32
+ async get(conversationId, agentId, fieldName) {
33
+ const result = await this.database.query(`SELECT analytics ? $3::text AS present,
34
+ analytics -> $3::text AS value
35
+ FROM conversations
36
+ WHERE conversation_id = $1
37
+ AND agent_id = $2`, [conversationId, agentId, fieldName]);
38
+ if (result.rows.length === 0 || !result.rows[0].present) {
39
+ return undefined;
40
+ }
41
+ return result.rows[0].value;
42
+ }
43
+ async getSummary(conversationId, agentId) {
44
+ const result = await this.database.query(`SELECT conversation_id, agent_id, channel, started_at
45
+ FROM conversations
46
+ WHERE conversation_id = $1
47
+ AND agent_id = $2`, [conversationId, agentId]);
48
+ const row = result.rows[0];
49
+ if (!row) {
50
+ return undefined;
51
+ }
52
+ return {
53
+ conversationId: row.conversation_id,
54
+ agentId: row.agent_id,
55
+ channel: row.channel,
56
+ startedAt: row.started_at,
57
+ };
58
+ }
59
+ }
60
+ exports.PostgresAnalytics = PostgresAnalytics;
61
+ function assertConversationBelongsToAgent(rowCount, conversationId, agentId) {
62
+ if (rowCount !== 1) {
63
+ throw new Error(`Conversation '${conversationId}' does not belong to agent '${agentId}'`);
64
+ }
65
+ }
66
+ class PostgresConversationRepository {
67
+ pool;
68
+ constructor(pool) {
69
+ this.pool = pool;
70
+ }
71
+ async saveConversation(conversation, phoneCall) {
72
+ const client = await this.pool.connect();
73
+ try {
74
+ await client.query('BEGIN');
75
+ await this.upsertConversation(client, conversation);
76
+ if (phoneCall) {
77
+ await this.upsertPhoneCall(client, conversation.conversationId, phoneCall);
78
+ }
79
+ await client.query('COMMIT');
80
+ }
81
+ catch (error) {
82
+ await client.query('ROLLBACK');
83
+ throw error;
84
+ }
85
+ finally {
86
+ client.release();
87
+ }
88
+ }
89
+ async upsertConversation(client, conversation) {
90
+ const result = await client.query(`INSERT INTO conversations (
91
+ conversation_id,
92
+ agent_id,
93
+ channel,
94
+ region,
95
+ started_at,
96
+ ended_at
97
+ ) VALUES ($1, $2, $3, $4, $5, $6)
98
+ ON CONFLICT (conversation_id) DO UPDATE SET
99
+ agent_id = COALESCE(conversations.agent_id, EXCLUDED.agent_id),
100
+ region = COALESCE(conversations.region, EXCLUDED.region),
101
+ ended_at = COALESCE(conversations.ended_at, EXCLUDED.ended_at),
102
+ updated_at = now()
103
+ WHERE conversations.agent_id IS NULL
104
+ OR conversations.agent_id = EXCLUDED.agent_id`, [
105
+ conversation.conversationId,
106
+ conversation.agentId,
107
+ conversation.channel,
108
+ conversation.region ?? null,
109
+ conversation.startedAt,
110
+ conversation.endedAt ?? null,
111
+ ]);
112
+ assertConversationBelongsToAgent(result.rowCount, conversation.conversationId, conversation.agentId);
113
+ }
114
+ async upsertPhoneCall(client, conversationId, phoneCall) {
115
+ await client.query(`INSERT INTO phone_calls (
116
+ conversation_id,
117
+ phone_number,
118
+ recording_url,
119
+ ended_reason,
120
+ duration_seconds
121
+ ) VALUES ($1, $2, $3, $4, $5)
122
+ ON CONFLICT (conversation_id) DO UPDATE SET
123
+ phone_number = COALESCE(phone_calls.phone_number, EXCLUDED.phone_number),
124
+ recording_url = COALESCE(phone_calls.recording_url, EXCLUDED.recording_url),
125
+ ended_reason = COALESCE(phone_calls.ended_reason, EXCLUDED.ended_reason),
126
+ duration_seconds = COALESCE(
127
+ phone_calls.duration_seconds,
128
+ EXCLUDED.duration_seconds
129
+ )`, [
130
+ conversationId,
131
+ phoneCall.phoneNumber ?? null,
132
+ phoneCall.recordingUrl ?? null,
133
+ phoneCall.endedReason ?? null,
134
+ phoneCall.durationSeconds ?? null,
135
+ ]);
136
+ }
137
+ }
138
+ exports.PostgresConversationRepository = PostgresConversationRepository;
139
+ const migrations = [
140
+ {
141
+ version: 1,
142
+ sql: `
143
+ -- Analytics rationale: This is the channel-neutral parent row and top-level JSONB map.
144
+ -- Removing it leaves typed values with no lifecycle or common dimensions to attach to.
145
+ CREATE TABLE conversations (
146
+ conversation_id text PRIMARY KEY,
147
+ agent_id text,
148
+ channel text NOT NULL,
149
+ region text,
150
+ started_at timestamptz NOT NULL,
151
+ ended_at timestamptz,
152
+ duration_seconds double precision CHECK (
153
+ duration_seconds IS NULL OR duration_seconds >= 0
154
+ ),
155
+ analytics jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (
156
+ jsonb_typeof(analytics) = 'object'
157
+ ),
158
+ created_at timestamptz NOT NULL DEFAULT now(),
159
+ updated_at timestamptz NOT NULL DEFAULT now()
160
+ );
161
+
162
+ -- Analytics rationale: Phone-only data stays in a one-to-one child so text conversations
163
+ -- do not acquire call-specific columns or assumptions.
164
+ CREATE TABLE phone_calls (
165
+ conversation_id text PRIMARY KEY REFERENCES conversations(conversation_id)
166
+ ON DELETE CASCADE,
167
+ phone_number text,
168
+ recording_url text,
169
+ ended_reason text
170
+ );
171
+
172
+ -- Analytics rationale: These indexes cover time-ordered listing and its common equality
173
+ -- filters; without them dashboards and Telescope lists degrade to full table scans.
174
+ CREATE INDEX conversations_started_at_idx
175
+ ON conversations (started_at DESC, conversation_id DESC);
176
+ CREATE INDEX conversations_agent_started_at_idx
177
+ ON conversations (agent_id, started_at DESC, conversation_id DESC);
178
+ CREATE INDEX conversations_region_started_at_idx
179
+ ON conversations (region, started_at DESC, conversation_id DESC);
180
+ CREATE INDEX conversations_channel_started_at_idx
181
+ ON conversations (channel, started_at DESC, conversation_id DESC);
182
+
183
+ -- Analytics rationale: GIN accelerates JSONB containment filters while the phone index
184
+ -- supports exact caller lookups joined through the conversation ID.
185
+ CREATE INDEX conversations_analytics_idx
186
+ ON conversations USING gin (analytics);
187
+ CREATE INDEX phone_calls_phone_number_idx
188
+ ON phone_calls (phone_number, conversation_id);
189
+ `,
190
+ },
191
+ {
192
+ version: 2,
193
+ sql: `
194
+ -- Analytics rationale: Enforce mandatory agent ownership for new and updated rows
195
+ -- without making deployment fail on historical conversations that have no agent ID.
196
+ ALTER TABLE conversations
197
+ ADD CONSTRAINT conversations_agent_id_required
198
+ CHECK (agent_id IS NOT NULL AND btrim(agent_id) <> '') NOT VALID;
199
+ `,
200
+ },
201
+ {
202
+ version: 3,
203
+ sql: `
204
+ -- Analytics rationale: Duration describes a phone call, not every conversation. Move
205
+ -- existing call durations before removing the phone-specific column from the parent.
206
+ ALTER TABLE phone_calls
207
+ ADD COLUMN duration_seconds double precision CHECK (
208
+ duration_seconds IS NULL OR duration_seconds >= 0
209
+ );
210
+
211
+ UPDATE phone_calls AS p
212
+ SET duration_seconds = c.duration_seconds
213
+ FROM conversations AS c
214
+ WHERE p.conversation_id = c.conversation_id
215
+ AND p.duration_seconds IS NULL
216
+ AND c.duration_seconds IS NOT NULL;
217
+
218
+ ALTER TABLE conversations
219
+ DROP COLUMN duration_seconds;
220
+ `,
221
+ },
222
+ ];
223
+ async function migrateAnalyticsDatabase(pool) {
224
+ const client = await pool.connect();
225
+ let lockAcquired = false;
226
+ let migrationFailed = false;
227
+ try {
228
+ await client.query("SELECT pg_advisory_lock(hashtext('recombine_analytics_migrations'))");
229
+ lockAcquired = true;
230
+ await client.query(`
231
+ CREATE TABLE IF NOT EXISTS analytics_schema_migrations (
232
+ version integer PRIMARY KEY,
233
+ applied_at timestamptz NOT NULL DEFAULT now()
234
+ )
235
+ `);
236
+ for (const migration of migrations) {
237
+ const applied = await client.query(`SELECT EXISTS (
238
+ SELECT 1 FROM analytics_schema_migrations WHERE version = $1
239
+ ) AS exists`, [migration.version]);
240
+ if (applied.rows[0].exists) {
241
+ continue;
242
+ }
243
+ await client.query('BEGIN');
244
+ try {
245
+ await client.query(migration.sql);
246
+ await client.query('INSERT INTO analytics_schema_migrations (version) VALUES ($1)', [migration.version]);
247
+ await client.query('COMMIT');
248
+ }
249
+ catch (error) {
250
+ await client.query('ROLLBACK');
251
+ throw error;
252
+ }
253
+ }
254
+ }
255
+ catch (error) {
256
+ migrationFailed = true;
257
+ throw error;
258
+ }
259
+ finally {
260
+ await releaseAnalyticsMigrationClient(client, lockAcquired, migrationFailed);
261
+ }
262
+ }
263
+ async function releaseAnalyticsMigrationClient(client, lockAcquired, migrationFailed) {
264
+ let discardClient = false;
265
+ try {
266
+ if (lockAcquired) {
267
+ await client.query("SELECT pg_advisory_unlock(hashtext('recombine_analytics_migrations'))");
268
+ }
269
+ }
270
+ catch (error) {
271
+ discardClient = true;
272
+ if (!migrationFailed) {
273
+ throw error;
274
+ }
275
+ }
276
+ finally {
277
+ client.release(discardClient);
278
+ }
279
+ }
280
+ async function createPostgresAnalyticsServices(config) {
281
+ const pool = new pg_1.Pool(config);
282
+ try {
283
+ await migrateAnalyticsDatabase(pool);
284
+ }
285
+ catch (error) {
286
+ await pool.end();
287
+ throw error;
288
+ }
289
+ return {
290
+ analytics: new PostgresAnalytics(pool),
291
+ conversations: new PostgresConversationRepository(pool),
292
+ queries: new postgres_analytics_query_1.PostgresAnalyticsQueryService(pool),
293
+ close: () => pool.end(),
294
+ };
295
+ }
@@ -1,5 +1,5 @@
1
1
  import { Logger } from '@recombine-ai/engine';
2
- import { ContextCache } from './context-cache.js';
2
+ import { ContextCache } from './context-cache';
3
3
  export type TimeoutsState = {
4
4
  timeouts: Record<string, number>;
5
5
  };
@@ -1 +1 @@
1
- {"version":3,"file":"timeouts-manager.d.ts","sourceRoot":"","sources":["../src/timeouts-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAE7C,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAKjD,MAAM,MAAM,aAAa,GAAG;IACxB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACnC,CAAA;AAKD,KAAK,kBAAkB,GAAG;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAC,OAAO,CAAA;CACxB,CAAA;AAED,qBAAa,cAAc;IAOnB,QAAQ,CAAC,aAAa,EAAE,YAAY,CAAC,aAAa,CAAC;IACnD,QAAQ,CAAC,MAAM,EAAE,MAAM;IAEvB,OAAO,CAAC,SAAS;IANrB,QAAQ,CAAC,mBAAmB,kDAAwD;gBAGvE,aAAa,EAAE,YAAY,CAAC,aAAa,CAAC,EAC1C,MAAM,EAAE,MAAM,EAEf,SAAS,UAAQ;IAQ7B,gBAAgB,CAAC,MAAM,EAAE,MAAM;IAYzB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAWlE,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM;IA4B7D,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM;IAmEvF,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYxD"}
1
+ {"version":3,"file":"timeouts-manager.d.ts","sourceRoot":"","sources":["../src/timeouts-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAE7C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAK9C,MAAM,MAAM,aAAa,GAAG;IACxB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACnC,CAAA;AAKD,KAAK,kBAAkB,GAAG;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAC,OAAO,CAAA;CACxB,CAAA;AAED,qBAAa,cAAc;IAOnB,QAAQ,CAAC,aAAa,EAAE,YAAY,CAAC,aAAa,CAAC;IACnD,QAAQ,CAAC,MAAM,EAAE,MAAM;IAEvB,OAAO,CAAC,SAAS;IANrB,QAAQ,CAAC,mBAAmB,kDAAwD;gBAGvE,aAAa,EAAE,YAAY,CAAC,aAAa,CAAC,EAC1C,MAAM,EAAE,MAAM,EAEf,SAAS,UAAQ;IAQ7B,gBAAgB,CAAC,MAAM,EAAE,MAAM;IAYzB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAWlE,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM;IA4B7D,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM;IAmEvF,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYxD"}
@@ -1,4 +1,7 @@
1
- export class TimeoutManager {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TimeoutManager = void 0;
4
+ class TimeoutManager {
2
5
  timeoutsCache;
3
6
  logger;
4
7
  shallSkip;
@@ -97,3 +100,4 @@ export class TimeoutManager {
97
100
  this.logger.debug(`TimeoutManager: cleared all timeouts`, { callId });
98
101
  }
99
102
  }
103
+ exports.TimeoutManager = TimeoutManager;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recombine-ai/platform",
3
- "version": "0.2.6-test.1",
3
+ "version": "0.2.6-test.10",
4
4
  "description": "Recombine AI: voice agent platform",
5
5
  "license": "EULA",
6
6
  "author": "recombine.ai",
@@ -12,21 +12,16 @@
12
12
  "files": [
13
13
  "build"
14
14
  ],
15
- "type": "module",
15
+ "main": "./build/index.js",
16
16
  "types": "./build/index.d.ts",
17
17
  "dependencies": {
18
18
  "@recombine-ai/engine": "1.0.0",
19
- "@recombine-ai/telescope": "0.2.0",
19
+ "@recombine-ai/telescope": "^0.3.0",
20
+ "@types/pg": "8.15.5",
21
+ "pg": "8.16.3",
20
22
  "redis": "5.11.0",
21
23
  "zod": "^4.4.3"
22
24
  },
23
- "exports": {
24
- ".": {
25
- "types": "./build/index.d.ts",
26
- "import": "./build/index.js",
27
- "default": "./build/index.js"
28
- }
29
- },
30
25
  "publishConfig": {
31
26
  "registry": "https://registry.npmjs.org"
32
27
  },
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=analytics.spec.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"analytics.spec.d.ts","sourceRoot":"","sources":["../src/analytics.spec.ts"],"names":[],"mappings":""}
@@ -1,57 +0,0 @@
1
- import { describe, expect, expectTypeOf, it } from 'vitest';
2
- import { z } from 'zod';
3
- import { analyticsKey, createInMemoryAnalytics } from './analytics.js';
4
- describe('Analytics', () => {
5
- it('rejects unsupported key characters', () => {
6
- expect(() => analyticsKey.string('not allowed')).toThrow('Analytics key contains unsupported characters');
7
- });
8
- it('keeps key and value types paired', async () => {
9
- const analytics = createInMemoryAnalytics();
10
- const escalated = analyticsKey.boolean('common.escalated');
11
- const conversation = analytics.getConversationAnalytics('conversation-1');
12
- await conversation.set(escalated, true);
13
- const value = await conversation.get(escalated);
14
- expectTypeOf(value).toEqualTypeOf();
15
- expect(value).toBe(true);
16
- });
17
- it('clears a value idempotently', async () => {
18
- const analytics = createInMemoryAnalytics();
19
- const score = analyticsKey.number('project.score');
20
- const conversation = analytics.getConversationAnalytics('conversation-1');
21
- await conversation.set(score, 3);
22
- await conversation.clear(score);
23
- await conversation.clear(score);
24
- await expect(conversation.get(score)).resolves.toBeUndefined();
25
- });
26
- it('validates JSON values at runtime', async () => {
27
- const analytics = createInMemoryAnalytics();
28
- const result = analyticsKey.json('project.result', z.object({ reason: z.string(), accepted: z.boolean() }));
29
- const conversation = analytics.getConversationAnalytics('conversation-1');
30
- await expect(conversation.set(result, {
31
- reason: 'done',
32
- accepted: true,
33
- })).resolves.toBeUndefined();
34
- await expect(conversation.set(result, {
35
- reason: 'done',
36
- accepted: 'yes',
37
- })).rejects.toThrow();
38
- });
39
- it('distinguishes JSON null from a missing key', async () => {
40
- const analytics = createInMemoryAnalytics();
41
- const result = analyticsKey.json('project.result', z.null());
42
- await analytics.getConversationAnalytics('conversation-1').set(result, null);
43
- await expect(analytics.getConversationAnalytics('conversation-1').get(result)).resolves.toBeNull();
44
- await expect(analytics.getConversationAnalytics('conversation-2').get(result)).resolves.toBeUndefined();
45
- });
46
- it('returns common conversation data from getSummary', async () => {
47
- const summary = {
48
- conversationId: 'conversation-1',
49
- agentId: 'support',
50
- channel: 'phone',
51
- startedAt: new Date('2026-08-22T10:00:00Z'),
52
- durationSeconds: 60,
53
- };
54
- const analytics = createInMemoryAnalytics([summary]);
55
- await expect(analytics.getConversationAnalytics('conversation-1').getSummary()).resolves.toEqual(summary);
56
- });
57
- });