@c4t4/heyamigo 0.9.5 → 0.9.6

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/dist/db/schema.js CHANGED
@@ -128,3 +128,54 @@ export const crons = sqliteTable('crons', {
128
128
  .on(t.name)
129
129
  .where(sql `${t.recurrence} IS NOT NULL`),
130
130
  }));
131
+ // ──────────────────────────────────────────────────────────────────
132
+ // Inbound queue (Phase 4)
133
+ // ──────────────────────────────────────────────────────────────────
134
+ // Messages received on any channel, waiting to be processed by a
135
+ // chat worker. The chat worker pool claims rows with per-address
136
+ // serialization (one in-flight job per chat preserves reply order),
137
+ // runs the AI provider, and enqueues outbound rows with the reply.
138
+ //
139
+ // `address` is the chat-level address (a group's address, or a DM's
140
+ // address). `actor_address` is the within-chat sender — for DMs it
141
+ // equals `address`; for groups it identifies which member sent the
142
+ // message. `person_id` and `actor_person_id` are the resolved
143
+ // person identities (nullable when resolution fails, e.g. an unknown
144
+ // member of a group).
145
+ //
146
+ // Idempotency: external_msg_id (channel-native message id, e.g.
147
+ // Baileys message key id). The same channel message arriving twice
148
+ // (network retransmit, replay on reconnect) maps to one row.
149
+ export const inbound = sqliteTable('inbound', {
150
+ id: integer('id').primaryKey({ autoIncrement: true }),
151
+ address: text('address').notNull(), // chat-level address
152
+ actorAddress: text('actor_address'), // sender within chat; null = self/system
153
+ personId: text('person_id'), // resolved chat owner (DM partner / group)
154
+ actorPersonId: text('actor_person_id'), // resolved sender
155
+ externalMsgId: text('external_msg_id'), // channel-native msg id (idempotency)
156
+ text: text('text').notNull(), // body or media tag
157
+ mediaPath: text('media_path'), // path relative to storage/ when media
158
+ mediaMime: text('media_mime'),
159
+ mediaBytes: integer('media_bytes'),
160
+ pushName: text('push_name'), // sender's display name at send time
161
+ triggerReason: text('trigger_reason'), // 'alias'|'mention'|'reply'|'owner'|...
162
+ // 'pending'|'claimed'|'done'|'failed'|'dlq'
163
+ status: text('status').notNull(),
164
+ attempts: integer('attempts').notNull().default(0),
165
+ nextAttemptAt: integer('next_attempt_at'),
166
+ lastError: text('last_error'),
167
+ claimedBy: text('claimed_by'),
168
+ claimedAt: integer('claimed_at'),
169
+ receivedAt: integer('received_at').notNull(), // unix sec when WA delivered
170
+ createdAt: integer('created_at').notNull(), // unix sec when row inserted
171
+ updatedAt: integer('updated_at').notNull(),
172
+ }, t => ({
173
+ byStatusNext: index('inbound_by_status_next').on(t.status, t.nextAttemptAt),
174
+ byAddress: index('inbound_by_address').on(t.address),
175
+ byPerson: index('inbound_by_person').on(t.personId, t.receivedAt),
176
+ // Sparse unique on external_msg_id: enforced only when set. Same
177
+ // pattern as outbound's idempotency_key.
178
+ uniqExtId: uniqueIndex('inbound_external_msg_id_uq')
179
+ .on(t.externalMsgId)
180
+ .where(sql `${t.externalMsgId} IS NOT NULL`),
181
+ }));
@@ -0,0 +1,190 @@
1
+ // Inbound queue helpers. Gateway (gateway/incoming.ts) calls
2
+ // enqueueInbound; chat workers (queue/chat-worker.ts) drain via
3
+ // claimNextInbound. Per-address serialization preserves reply order
4
+ // within a chat while different chats run in parallel.
5
+ //
6
+ // Same primitives as outbound (claim/done/retry/dlq) with the added
7
+ // `address NOT IN (claimed)` filter in the claim query.
8
+ import { and, asc, eq, isNull, lte, notInArray, or, sql } from 'drizzle-orm';
9
+ import { getDb } from '../db/index.js';
10
+ import { inbound } from '../db/schema.js';
11
+ // Idempotent on external_msg_id when set. Same channel message
12
+ // arriving twice (Baileys replay, network retransmit) returns the
13
+ // existing row instead of duplicating.
14
+ export function enqueueInbound(input) {
15
+ const db = getDb();
16
+ const now = Math.floor(Date.now() / 1000);
17
+ if (input.externalMsgId) {
18
+ const found = db
19
+ .select()
20
+ .from(inbound)
21
+ .where(eq(inbound.externalMsgId, input.externalMsgId))
22
+ .get();
23
+ if (found)
24
+ return { inserted: false, row: found };
25
+ }
26
+ const row = db
27
+ .insert(inbound)
28
+ .values({
29
+ address: input.address,
30
+ actorAddress: input.actorAddress ?? null,
31
+ personId: input.personId ?? null,
32
+ actorPersonId: input.actorPersonId ?? null,
33
+ externalMsgId: input.externalMsgId ?? null,
34
+ text: input.text,
35
+ mediaPath: input.mediaPath ?? null,
36
+ mediaMime: input.mediaMime ?? null,
37
+ mediaBytes: input.mediaBytes ?? null,
38
+ pushName: input.pushName ?? null,
39
+ triggerReason: input.triggerReason ?? null,
40
+ status: 'pending',
41
+ attempts: 0,
42
+ nextAttemptAt: null,
43
+ lastError: null,
44
+ claimedBy: null,
45
+ claimedAt: null,
46
+ receivedAt: input.receivedAt ?? now,
47
+ createdAt: now,
48
+ updatedAt: now,
49
+ })
50
+ .returning()
51
+ .get();
52
+ return { inserted: true, row };
53
+ }
54
+ // Atomic claim with per-address serialization. Skips any pending row
55
+ // whose address already has another row in `claimed` state →
56
+ // preserves reply order per chat while letting different chats run
57
+ // in parallel.
58
+ export function claimNextInbound(workerId) {
59
+ const db = getDb();
60
+ const now = Math.floor(Date.now() / 1000);
61
+ return db.transaction((tx) => {
62
+ // Subquery: addresses currently claimed (= one in-flight per chat).
63
+ const busyAddrs = tx
64
+ .select({ address: inbound.address })
65
+ .from(inbound)
66
+ .where(eq(inbound.status, 'claimed'))
67
+ .all()
68
+ .map((r) => r.address);
69
+ const conds = [
70
+ eq(inbound.status, 'pending'),
71
+ or(isNull(inbound.nextAttemptAt), lte(inbound.nextAttemptAt, now)),
72
+ ];
73
+ if (busyAddrs.length > 0) {
74
+ conds.push(notInArray(inbound.address, busyAddrs));
75
+ }
76
+ const target = tx
77
+ .select({ id: inbound.id })
78
+ .from(inbound)
79
+ .where(and(...conds))
80
+ .orderBy(asc(inbound.id))
81
+ .limit(1)
82
+ .get();
83
+ if (!target)
84
+ return null;
85
+ const claimed = tx
86
+ .update(inbound)
87
+ .set({
88
+ status: 'claimed',
89
+ claimedBy: workerId,
90
+ claimedAt: now,
91
+ updatedAt: now,
92
+ })
93
+ .where(and(eq(inbound.id, target.id), eq(inbound.status, 'pending')))
94
+ .returning()
95
+ .get();
96
+ return claimed ?? null;
97
+ });
98
+ }
99
+ export function markInboundDone(id, workerId) {
100
+ const db = getDb();
101
+ const now = Math.floor(Date.now() / 1000);
102
+ const result = db
103
+ .update(inbound)
104
+ .set({ status: 'done', updatedAt: now })
105
+ .where(and(eq(inbound.id, id), eq(inbound.status, 'claimed'), eq(inbound.claimedBy, workerId)))
106
+ .returning({ id: inbound.id })
107
+ .all();
108
+ return result.length > 0;
109
+ }
110
+ // Backoff: 5s, 30s, 2min, 5min, give up. Bigger gaps than outbound
111
+ // because chat replies are expensive (AI call); a faster retry loop
112
+ // would just burn tokens on transient errors.
113
+ const BACKOFF_SECONDS = [5, 30, 120, 300];
114
+ const MAX_ATTEMPTS = BACKOFF_SECONDS.length;
115
+ export function markInboundRetryOrDlq(id, workerId, errorMessage) {
116
+ const db = getDb();
117
+ return db.transaction((tx) => {
118
+ const row = tx.select().from(inbound).where(eq(inbound.id, id)).get();
119
+ if (!row || row.status !== 'claimed' || row.claimedBy !== workerId) {
120
+ return { retried: false, deadLettered: false };
121
+ }
122
+ const now = Math.floor(Date.now() / 1000);
123
+ const nextAttempts = row.attempts + 1;
124
+ if (nextAttempts > MAX_ATTEMPTS) {
125
+ tx.update(inbound)
126
+ .set({
127
+ status: 'dlq',
128
+ attempts: nextAttempts,
129
+ lastError: errorMessage,
130
+ claimedBy: null,
131
+ claimedAt: null,
132
+ updatedAt: now,
133
+ })
134
+ .where(eq(inbound.id, id))
135
+ .run();
136
+ return { retried: false, deadLettered: true };
137
+ }
138
+ const backoff = BACKOFF_SECONDS[Math.min(row.attempts, BACKOFF_SECONDS.length - 1)];
139
+ tx.update(inbound)
140
+ .set({
141
+ status: 'pending',
142
+ attempts: nextAttempts,
143
+ nextAttemptAt: now + backoff,
144
+ lastError: errorMessage,
145
+ claimedBy: null,
146
+ claimedAt: null,
147
+ updatedAt: now,
148
+ })
149
+ .where(eq(inbound.id, id))
150
+ .run();
151
+ return { retried: true, deadLettered: false };
152
+ });
153
+ }
154
+ export function markInboundFailed(id, workerId, errorMessage) {
155
+ const db = getDb();
156
+ const now = Math.floor(Date.now() / 1000);
157
+ const result = db
158
+ .update(inbound)
159
+ .set({
160
+ status: 'failed',
161
+ lastError: errorMessage,
162
+ claimedBy: null,
163
+ claimedAt: null,
164
+ updatedAt: now,
165
+ })
166
+ .where(and(eq(inbound.id, id), eq(inbound.status, 'claimed'), eq(inbound.claimedBy, workerId)))
167
+ .returning({ id: inbound.id })
168
+ .all();
169
+ return result.length > 0;
170
+ }
171
+ // Orchestrator helper. Chat workers run longer than sender workers
172
+ // (AI calls + memory writes), so the TTL is more generous. 300s
173
+ // matches the typical chat-track timeout (5min).
174
+ const CLAIM_TTL_SECONDS = 360;
175
+ export function reclaimStuckInbound() {
176
+ const db = getDb();
177
+ const cutoff = Math.floor(Date.now() / 1000) - CLAIM_TTL_SECONDS;
178
+ const result = db
179
+ .update(inbound)
180
+ .set({
181
+ status: 'pending',
182
+ claimedBy: null,
183
+ claimedAt: null,
184
+ updatedAt: sql `${inbound.updatedAt}`,
185
+ })
186
+ .where(and(eq(inbound.status, 'claimed'), lte(inbound.claimedAt, cutoff)))
187
+ .returning({ id: inbound.id })
188
+ .all();
189
+ return result.length;
190
+ }
@@ -16,6 +16,7 @@ import { and, eq, lt, ne } from 'drizzle-orm';
16
16
  import { getDb } from '../db/index.js';
17
17
  import { workers } from '../db/schema.js';
18
18
  import { logger } from '../logger.js';
19
+ import { reclaimStuckInbound } from './inbound.js';
19
20
  import { reclaimStuckOutbound } from './outbound.js';
20
21
  import { clearControl, readControl, requestControl } from './control.js';
21
22
  import { listDueCrons, markCronFired } from './crons.js';
@@ -98,9 +99,13 @@ async function tick(id) {
98
99
  .run();
99
100
  }
100
101
  // Cross-queue housekeeping. More queues land in later phases.
101
- const reclaimed = reclaimStuckOutbound();
102
- if (reclaimed > 0) {
103
- logger.info({ reclaimed }, 'reclaimed stuck outbound rows');
102
+ const reclaimedOutbound = reclaimStuckOutbound();
103
+ if (reclaimedOutbound > 0) {
104
+ logger.info({ reclaimed: reclaimedOutbound }, 'reclaimed stuck outbound rows');
105
+ }
106
+ const reclaimedInbound = reclaimStuckInbound();
107
+ if (reclaimedInbound > 0) {
108
+ logger.info({ reclaimed: reclaimedInbound }, 'reclaimed stuck inbound rows');
104
109
  }
105
110
  // Fire any due crons. Order: dispatch each in turn; if dispatch
106
111
  // throws (it shouldn't — dispatch swallows), the cron is NOT
@@ -0,0 +1,28 @@
1
+ CREATE TABLE `inbound` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `address` text NOT NULL,
4
+ `actor_address` text,
5
+ `person_id` text,
6
+ `actor_person_id` text,
7
+ `external_msg_id` text,
8
+ `text` text NOT NULL,
9
+ `media_path` text,
10
+ `media_mime` text,
11
+ `media_bytes` integer,
12
+ `push_name` text,
13
+ `trigger_reason` text,
14
+ `status` text NOT NULL,
15
+ `attempts` integer DEFAULT 0 NOT NULL,
16
+ `next_attempt_at` integer,
17
+ `last_error` text,
18
+ `claimed_by` text,
19
+ `claimed_at` integer,
20
+ `received_at` integer NOT NULL,
21
+ `created_at` integer NOT NULL,
22
+ `updated_at` integer NOT NULL
23
+ );
24
+ --> statement-breakpoint
25
+ CREATE INDEX `inbound_by_status_next` ON `inbound` (`status`,`next_attempt_at`);--> statement-breakpoint
26
+ CREATE INDEX `inbound_by_address` ON `inbound` (`address`);--> statement-breakpoint
27
+ CREATE INDEX `inbound_by_person` ON `inbound` (`person_id`,`received_at`);--> statement-breakpoint
28
+ CREATE UNIQUE INDEX `inbound_external_msg_id_uq` ON `inbound` (`external_msg_id`) WHERE "inbound"."external_msg_id" IS NOT NULL;
@@ -0,0 +1,658 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "155b0aa7-93ec-4d7a-a853-e680b47d162c",
5
+ "prevId": "417b2708-624e-416f-af59-bee5d5e38556",
6
+ "tables": {
7
+ "control": {
8
+ "name": "control",
9
+ "columns": {
10
+ "key": {
11
+ "name": "key",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "value": {
18
+ "name": "value",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": false,
22
+ "autoincrement": false
23
+ },
24
+ "requested_by": {
25
+ "name": "requested_by",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": false,
29
+ "autoincrement": false
30
+ },
31
+ "requested_at": {
32
+ "name": "requested_at",
33
+ "type": "integer",
34
+ "primaryKey": false,
35
+ "notNull": true,
36
+ "autoincrement": false
37
+ }
38
+ },
39
+ "indexes": {},
40
+ "foreignKeys": {},
41
+ "compositePrimaryKeys": {},
42
+ "uniqueConstraints": {},
43
+ "checkConstraints": {}
44
+ },
45
+ "crons": {
46
+ "name": "crons",
47
+ "columns": {
48
+ "id": {
49
+ "name": "id",
50
+ "type": "integer",
51
+ "primaryKey": true,
52
+ "notNull": true,
53
+ "autoincrement": true
54
+ },
55
+ "name": {
56
+ "name": "name",
57
+ "type": "text",
58
+ "primaryKey": false,
59
+ "notNull": true,
60
+ "autoincrement": false
61
+ },
62
+ "enqueue_into": {
63
+ "name": "enqueue_into",
64
+ "type": "text",
65
+ "primaryKey": false,
66
+ "notNull": true,
67
+ "autoincrement": false
68
+ },
69
+ "payload": {
70
+ "name": "payload",
71
+ "type": "text",
72
+ "primaryKey": false,
73
+ "notNull": true,
74
+ "autoincrement": false
75
+ },
76
+ "recurrence": {
77
+ "name": "recurrence",
78
+ "type": "text",
79
+ "primaryKey": false,
80
+ "notNull": false,
81
+ "autoincrement": false
82
+ },
83
+ "next_run_at": {
84
+ "name": "next_run_at",
85
+ "type": "integer",
86
+ "primaryKey": false,
87
+ "notNull": true,
88
+ "autoincrement": false
89
+ },
90
+ "last_run_at": {
91
+ "name": "last_run_at",
92
+ "type": "integer",
93
+ "primaryKey": false,
94
+ "notNull": false,
95
+ "autoincrement": false
96
+ },
97
+ "enabled": {
98
+ "name": "enabled",
99
+ "type": "integer",
100
+ "primaryKey": false,
101
+ "notNull": true,
102
+ "autoincrement": false,
103
+ "default": 1
104
+ },
105
+ "created_at": {
106
+ "name": "created_at",
107
+ "type": "integer",
108
+ "primaryKey": false,
109
+ "notNull": true,
110
+ "autoincrement": false
111
+ }
112
+ },
113
+ "indexes": {
114
+ "crons_by_due": {
115
+ "name": "crons_by_due",
116
+ "columns": [
117
+ "enabled",
118
+ "next_run_at"
119
+ ],
120
+ "isUnique": false
121
+ },
122
+ "crons_name_uq": {
123
+ "name": "crons_name_uq",
124
+ "columns": [
125
+ "name"
126
+ ],
127
+ "isUnique": true,
128
+ "where": "\"crons\".\"recurrence\" IS NOT NULL"
129
+ }
130
+ },
131
+ "foreignKeys": {},
132
+ "compositePrimaryKeys": {},
133
+ "uniqueConstraints": {},
134
+ "checkConstraints": {}
135
+ },
136
+ "identities": {
137
+ "name": "identities",
138
+ "columns": {
139
+ "person_id": {
140
+ "name": "person_id",
141
+ "type": "text",
142
+ "primaryKey": false,
143
+ "notNull": true,
144
+ "autoincrement": false
145
+ },
146
+ "address": {
147
+ "name": "address",
148
+ "type": "text",
149
+ "primaryKey": false,
150
+ "notNull": true,
151
+ "autoincrement": false
152
+ },
153
+ "added_at": {
154
+ "name": "added_at",
155
+ "type": "integer",
156
+ "primaryKey": false,
157
+ "notNull": true,
158
+ "autoincrement": false
159
+ }
160
+ },
161
+ "indexes": {
162
+ "identities_address_unique": {
163
+ "name": "identities_address_unique",
164
+ "columns": [
165
+ "address"
166
+ ],
167
+ "isUnique": true
168
+ }
169
+ },
170
+ "foreignKeys": {
171
+ "identities_person_id_persons_id_fk": {
172
+ "name": "identities_person_id_persons_id_fk",
173
+ "tableFrom": "identities",
174
+ "tableTo": "persons",
175
+ "columnsFrom": [
176
+ "person_id"
177
+ ],
178
+ "columnsTo": [
179
+ "id"
180
+ ],
181
+ "onDelete": "no action",
182
+ "onUpdate": "no action"
183
+ }
184
+ },
185
+ "compositePrimaryKeys": {
186
+ "identities_person_id_address_pk": {
187
+ "columns": [
188
+ "person_id",
189
+ "address"
190
+ ],
191
+ "name": "identities_person_id_address_pk"
192
+ }
193
+ },
194
+ "uniqueConstraints": {},
195
+ "checkConstraints": {}
196
+ },
197
+ "inbound": {
198
+ "name": "inbound",
199
+ "columns": {
200
+ "id": {
201
+ "name": "id",
202
+ "type": "integer",
203
+ "primaryKey": true,
204
+ "notNull": true,
205
+ "autoincrement": true
206
+ },
207
+ "address": {
208
+ "name": "address",
209
+ "type": "text",
210
+ "primaryKey": false,
211
+ "notNull": true,
212
+ "autoincrement": false
213
+ },
214
+ "actor_address": {
215
+ "name": "actor_address",
216
+ "type": "text",
217
+ "primaryKey": false,
218
+ "notNull": false,
219
+ "autoincrement": false
220
+ },
221
+ "person_id": {
222
+ "name": "person_id",
223
+ "type": "text",
224
+ "primaryKey": false,
225
+ "notNull": false,
226
+ "autoincrement": false
227
+ },
228
+ "actor_person_id": {
229
+ "name": "actor_person_id",
230
+ "type": "text",
231
+ "primaryKey": false,
232
+ "notNull": false,
233
+ "autoincrement": false
234
+ },
235
+ "external_msg_id": {
236
+ "name": "external_msg_id",
237
+ "type": "text",
238
+ "primaryKey": false,
239
+ "notNull": false,
240
+ "autoincrement": false
241
+ },
242
+ "text": {
243
+ "name": "text",
244
+ "type": "text",
245
+ "primaryKey": false,
246
+ "notNull": true,
247
+ "autoincrement": false
248
+ },
249
+ "media_path": {
250
+ "name": "media_path",
251
+ "type": "text",
252
+ "primaryKey": false,
253
+ "notNull": false,
254
+ "autoincrement": false
255
+ },
256
+ "media_mime": {
257
+ "name": "media_mime",
258
+ "type": "text",
259
+ "primaryKey": false,
260
+ "notNull": false,
261
+ "autoincrement": false
262
+ },
263
+ "media_bytes": {
264
+ "name": "media_bytes",
265
+ "type": "integer",
266
+ "primaryKey": false,
267
+ "notNull": false,
268
+ "autoincrement": false
269
+ },
270
+ "push_name": {
271
+ "name": "push_name",
272
+ "type": "text",
273
+ "primaryKey": false,
274
+ "notNull": false,
275
+ "autoincrement": false
276
+ },
277
+ "trigger_reason": {
278
+ "name": "trigger_reason",
279
+ "type": "text",
280
+ "primaryKey": false,
281
+ "notNull": false,
282
+ "autoincrement": false
283
+ },
284
+ "status": {
285
+ "name": "status",
286
+ "type": "text",
287
+ "primaryKey": false,
288
+ "notNull": true,
289
+ "autoincrement": false
290
+ },
291
+ "attempts": {
292
+ "name": "attempts",
293
+ "type": "integer",
294
+ "primaryKey": false,
295
+ "notNull": true,
296
+ "autoincrement": false,
297
+ "default": 0
298
+ },
299
+ "next_attempt_at": {
300
+ "name": "next_attempt_at",
301
+ "type": "integer",
302
+ "primaryKey": false,
303
+ "notNull": false,
304
+ "autoincrement": false
305
+ },
306
+ "last_error": {
307
+ "name": "last_error",
308
+ "type": "text",
309
+ "primaryKey": false,
310
+ "notNull": false,
311
+ "autoincrement": false
312
+ },
313
+ "claimed_by": {
314
+ "name": "claimed_by",
315
+ "type": "text",
316
+ "primaryKey": false,
317
+ "notNull": false,
318
+ "autoincrement": false
319
+ },
320
+ "claimed_at": {
321
+ "name": "claimed_at",
322
+ "type": "integer",
323
+ "primaryKey": false,
324
+ "notNull": false,
325
+ "autoincrement": false
326
+ },
327
+ "received_at": {
328
+ "name": "received_at",
329
+ "type": "integer",
330
+ "primaryKey": false,
331
+ "notNull": true,
332
+ "autoincrement": false
333
+ },
334
+ "created_at": {
335
+ "name": "created_at",
336
+ "type": "integer",
337
+ "primaryKey": false,
338
+ "notNull": true,
339
+ "autoincrement": false
340
+ },
341
+ "updated_at": {
342
+ "name": "updated_at",
343
+ "type": "integer",
344
+ "primaryKey": false,
345
+ "notNull": true,
346
+ "autoincrement": false
347
+ }
348
+ },
349
+ "indexes": {
350
+ "inbound_by_status_next": {
351
+ "name": "inbound_by_status_next",
352
+ "columns": [
353
+ "status",
354
+ "next_attempt_at"
355
+ ],
356
+ "isUnique": false
357
+ },
358
+ "inbound_by_address": {
359
+ "name": "inbound_by_address",
360
+ "columns": [
361
+ "address"
362
+ ],
363
+ "isUnique": false
364
+ },
365
+ "inbound_by_person": {
366
+ "name": "inbound_by_person",
367
+ "columns": [
368
+ "person_id",
369
+ "received_at"
370
+ ],
371
+ "isUnique": false
372
+ },
373
+ "inbound_external_msg_id_uq": {
374
+ "name": "inbound_external_msg_id_uq",
375
+ "columns": [
376
+ "external_msg_id"
377
+ ],
378
+ "isUnique": true,
379
+ "where": "\"inbound\".\"external_msg_id\" IS NOT NULL"
380
+ }
381
+ },
382
+ "foreignKeys": {},
383
+ "compositePrimaryKeys": {},
384
+ "uniqueConstraints": {},
385
+ "checkConstraints": {}
386
+ },
387
+ "outbound": {
388
+ "name": "outbound",
389
+ "columns": {
390
+ "id": {
391
+ "name": "id",
392
+ "type": "integer",
393
+ "primaryKey": true,
394
+ "notNull": true,
395
+ "autoincrement": true
396
+ },
397
+ "address": {
398
+ "name": "address",
399
+ "type": "text",
400
+ "primaryKey": false,
401
+ "notNull": true,
402
+ "autoincrement": false
403
+ },
404
+ "kind": {
405
+ "name": "kind",
406
+ "type": "text",
407
+ "primaryKey": false,
408
+ "notNull": true,
409
+ "autoincrement": false
410
+ },
411
+ "text": {
412
+ "name": "text",
413
+ "type": "text",
414
+ "primaryKey": false,
415
+ "notNull": false,
416
+ "autoincrement": false
417
+ },
418
+ "media_path": {
419
+ "name": "media_path",
420
+ "type": "text",
421
+ "primaryKey": false,
422
+ "notNull": false,
423
+ "autoincrement": false
424
+ },
425
+ "media_mime": {
426
+ "name": "media_mime",
427
+ "type": "text",
428
+ "primaryKey": false,
429
+ "notNull": false,
430
+ "autoincrement": false
431
+ },
432
+ "media_bytes": {
433
+ "name": "media_bytes",
434
+ "type": "integer",
435
+ "primaryKey": false,
436
+ "notNull": false,
437
+ "autoincrement": false
438
+ },
439
+ "quote_msg_id": {
440
+ "name": "quote_msg_id",
441
+ "type": "text",
442
+ "primaryKey": false,
443
+ "notNull": false,
444
+ "autoincrement": false
445
+ },
446
+ "idempotency_key": {
447
+ "name": "idempotency_key",
448
+ "type": "text",
449
+ "primaryKey": false,
450
+ "notNull": false,
451
+ "autoincrement": false
452
+ },
453
+ "status": {
454
+ "name": "status",
455
+ "type": "text",
456
+ "primaryKey": false,
457
+ "notNull": true,
458
+ "autoincrement": false
459
+ },
460
+ "attempts": {
461
+ "name": "attempts",
462
+ "type": "integer",
463
+ "primaryKey": false,
464
+ "notNull": true,
465
+ "autoincrement": false,
466
+ "default": 0
467
+ },
468
+ "next_attempt_at": {
469
+ "name": "next_attempt_at",
470
+ "type": "integer",
471
+ "primaryKey": false,
472
+ "notNull": false,
473
+ "autoincrement": false
474
+ },
475
+ "last_error": {
476
+ "name": "last_error",
477
+ "type": "text",
478
+ "primaryKey": false,
479
+ "notNull": false,
480
+ "autoincrement": false
481
+ },
482
+ "claimed_by": {
483
+ "name": "claimed_by",
484
+ "type": "text",
485
+ "primaryKey": false,
486
+ "notNull": false,
487
+ "autoincrement": false
488
+ },
489
+ "claimed_at": {
490
+ "name": "claimed_at",
491
+ "type": "integer",
492
+ "primaryKey": false,
493
+ "notNull": false,
494
+ "autoincrement": false
495
+ },
496
+ "created_at": {
497
+ "name": "created_at",
498
+ "type": "integer",
499
+ "primaryKey": false,
500
+ "notNull": true,
501
+ "autoincrement": false
502
+ },
503
+ "updated_at": {
504
+ "name": "updated_at",
505
+ "type": "integer",
506
+ "primaryKey": false,
507
+ "notNull": true,
508
+ "autoincrement": false
509
+ }
510
+ },
511
+ "indexes": {
512
+ "outbound_by_status_next": {
513
+ "name": "outbound_by_status_next",
514
+ "columns": [
515
+ "status",
516
+ "next_attempt_at"
517
+ ],
518
+ "isUnique": false
519
+ },
520
+ "outbound_by_address": {
521
+ "name": "outbound_by_address",
522
+ "columns": [
523
+ "address"
524
+ ],
525
+ "isUnique": false
526
+ },
527
+ "outbound_idempotency_key_uq": {
528
+ "name": "outbound_idempotency_key_uq",
529
+ "columns": [
530
+ "idempotency_key"
531
+ ],
532
+ "isUnique": true,
533
+ "where": "\"outbound\".\"idempotency_key\" IS NOT NULL"
534
+ }
535
+ },
536
+ "foreignKeys": {},
537
+ "compositePrimaryKeys": {},
538
+ "uniqueConstraints": {},
539
+ "checkConstraints": {}
540
+ },
541
+ "persons": {
542
+ "name": "persons",
543
+ "columns": {
544
+ "id": {
545
+ "name": "id",
546
+ "type": "text",
547
+ "primaryKey": true,
548
+ "notNull": true,
549
+ "autoincrement": false
550
+ },
551
+ "display_name": {
552
+ "name": "display_name",
553
+ "type": "text",
554
+ "primaryKey": false,
555
+ "notNull": false,
556
+ "autoincrement": false
557
+ },
558
+ "timezone": {
559
+ "name": "timezone",
560
+ "type": "text",
561
+ "primaryKey": false,
562
+ "notNull": false,
563
+ "autoincrement": false
564
+ },
565
+ "created_at": {
566
+ "name": "created_at",
567
+ "type": "integer",
568
+ "primaryKey": false,
569
+ "notNull": true,
570
+ "autoincrement": false
571
+ }
572
+ },
573
+ "indexes": {},
574
+ "foreignKeys": {},
575
+ "compositePrimaryKeys": {},
576
+ "uniqueConstraints": {},
577
+ "checkConstraints": {}
578
+ },
579
+ "workers": {
580
+ "name": "workers",
581
+ "columns": {
582
+ "id": {
583
+ "name": "id",
584
+ "type": "text",
585
+ "primaryKey": true,
586
+ "notNull": true,
587
+ "autoincrement": false
588
+ },
589
+ "kind": {
590
+ "name": "kind",
591
+ "type": "text",
592
+ "primaryKey": false,
593
+ "notNull": true,
594
+ "autoincrement": false
595
+ },
596
+ "status": {
597
+ "name": "status",
598
+ "type": "text",
599
+ "primaryKey": false,
600
+ "notNull": true,
601
+ "autoincrement": false
602
+ },
603
+ "current_job": {
604
+ "name": "current_job",
605
+ "type": "text",
606
+ "primaryKey": false,
607
+ "notNull": false,
608
+ "autoincrement": false
609
+ },
610
+ "last_seen": {
611
+ "name": "last_seen",
612
+ "type": "integer",
613
+ "primaryKey": false,
614
+ "notNull": true,
615
+ "autoincrement": false
616
+ },
617
+ "started_at": {
618
+ "name": "started_at",
619
+ "type": "integer",
620
+ "primaryKey": false,
621
+ "notNull": true,
622
+ "autoincrement": false
623
+ }
624
+ },
625
+ "indexes": {
626
+ "workers_by_kind_status": {
627
+ "name": "workers_by_kind_status",
628
+ "columns": [
629
+ "kind",
630
+ "status"
631
+ ],
632
+ "isUnique": false
633
+ },
634
+ "workers_by_last_seen": {
635
+ "name": "workers_by_last_seen",
636
+ "columns": [
637
+ "last_seen"
638
+ ],
639
+ "isUnique": false
640
+ }
641
+ },
642
+ "foreignKeys": {},
643
+ "compositePrimaryKeys": {},
644
+ "uniqueConstraints": {},
645
+ "checkConstraints": {}
646
+ }
647
+ },
648
+ "views": {},
649
+ "enums": {},
650
+ "_meta": {
651
+ "schemas": {},
652
+ "tables": {},
653
+ "columns": {}
654
+ },
655
+ "internal": {
656
+ "indexes": {}
657
+ }
658
+ }
@@ -22,6 +22,13 @@
22
22
  "when": 1779671387894,
23
23
  "tag": "0002_phase2_crons",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "6",
29
+ "when": 1779672156371,
30
+ "tag": "0003_phase4_inbound",
31
+ "breakpoints": true
25
32
  }
26
33
  ]
27
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "WhatsApp AI bot powered by Claude with long-term memory, browser control, and role-based access",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",