@c4t4/heyamigo 0.9.4 → 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
+ }));
@@ -2,6 +2,8 @@ import fastq from 'fastq';
2
2
  import { config } from '../config.js';
3
3
  import { logger } from '../logger.js';
4
4
  import { prunePrompts } from '../promptlog.js';
5
+ import { registerInternalCronHandler } from '../queue/cron-handlers.js';
6
+ import { deleteCron, enqueueCron } from '../queue/crons.js';
5
7
  import { pruneMedia } from '../store/media.js';
6
8
  import { runDigest } from './digest.js';
7
9
  import { ensureScaffold, getLastDigestedAt, jsonlMtimeFor, loadDigestState, } from './store.js';
@@ -74,7 +76,6 @@ async function sweep() {
74
76
  }
75
77
  }
76
78
  let sweepTimer = null;
77
- let nudgeTimer = null;
78
79
  const NUDGE_TICK_MS = 5 * 60 * 1000; // 5 minutes
79
80
  export function startScheduler() {
80
81
  if (sweepTimer)
@@ -95,13 +96,17 @@ export function startScheduler() {
95
96
  sweepTimer = setInterval(() => {
96
97
  void sweep().catch((err) => logger.error({ err }, 'sweep failed'));
97
98
  }, config.memory.sweepIntervalMs);
98
- // Faster tick just for proactive journal nudges (check-ins, silent-nudges).
99
- // The memory-sweep cycle (default 3h) is too coarse for a "daily 21:00"
100
- // check-in. This tick is cheap: it only spawns Claude when something is
101
- // actually due for a journal.
102
- nudgeTimer = setInterval(() => {
103
- void runNudgeTickSafe();
104
- }, NUDGE_TICK_MS);
99
+ // Proactive journal nudges (check-ins, silent-nudges). Migrated from
100
+ // setInterval to a cron row orchestrator. Same cadence, same body;
101
+ // benefits are: survives restarts, visible in `crons` table, can be
102
+ // paused via control row without code change.
103
+ registerInternalCronHandler('journal-nudge-tick', runNudgeTickSafe);
104
+ enqueueCron({
105
+ name: 'journal-nudge-tick',
106
+ enqueueInto: 'internal',
107
+ payload: { handler: 'journal-nudge-tick' },
108
+ recurrence: `@every ${Math.floor(NUDGE_TICK_MS / 1000)}s`,
109
+ });
105
110
  logger.info({
106
111
  intervalMs: config.memory.sweepIntervalMs,
107
112
  nudgeTickMs: NUDGE_TICK_MS,
@@ -121,11 +126,18 @@ export function stopScheduler() {
121
126
  clearInterval(sweepTimer);
122
127
  sweepTimer = null;
123
128
  }
124
- if (nudgeTimer) {
125
- clearInterval(nudgeTimer);
126
- nudgeTimer = null;
127
- }
129
+ // Nudge cron is owned by the crons table; orchestrator stops on its
130
+ // own. Deleting the cron row here would re-arm itself on next boot,
131
+ // so leave it alone — disabling via the `enabled` column is the
132
+ // user-facing knob.
128
133
  for (const t of pendingTimers.values())
129
134
  clearTimeout(t);
130
135
  pendingTimers.clear();
131
136
  }
137
+ // Exported for callers (CLI, /nudge command) that want to surgically
138
+ // disable nudges without editing config. Use `setCronEnabled` from
139
+ // crons.ts for the on/off switch; this is a hard delete (regenerated
140
+ // on next startScheduler call).
141
+ export function deleteNudgeCron() {
142
+ return deleteCron('journal-nudge-tick');
143
+ }
@@ -5,6 +5,7 @@
5
5
  // in on later phases — when those queues exist, add their dispatch
6
6
  // here and a cron can fire into them with no other changes.
7
7
  import { logger } from '../logger.js';
8
+ import { getInternalCronHandler } from './cron-handlers.js';
8
9
  import { enqueueOutbound } from './outbound.js';
9
10
  export function dispatchCron(row) {
10
11
  let payload;
@@ -19,6 +20,9 @@ export function dispatchCron(row) {
19
20
  case 'outbound':
20
21
  dispatchOutbound(row, payload);
21
22
  return;
23
+ case 'internal':
24
+ dispatchInternal(row, payload);
25
+ return;
22
26
  case 'inbound':
23
27
  case 'async':
24
28
  case 'memory_writes':
@@ -28,6 +32,34 @@ export function dispatchCron(row) {
28
32
  logger.error({ name: row.name, target: row.enqueueInto }, 'cron has unknown target queue');
29
33
  }
30
34
  }
35
+ function dispatchInternal(row, payload) {
36
+ if (!payload || typeof payload !== 'object' || !('handler' in payload)) {
37
+ logger.error({ id: row.id, name: row.name }, "internal cron missing 'handler' in payload");
38
+ return;
39
+ }
40
+ const handlerName = payload.handler;
41
+ if (typeof handlerName !== 'string') {
42
+ logger.error({ id: row.id, name: row.name }, 'internal cron handler name not a string');
43
+ return;
44
+ }
45
+ const handler = getInternalCronHandler(handlerName);
46
+ if (!handler) {
47
+ logger.error({ id: row.id, name: row.name, handler: handlerName }, 'internal cron handler not registered');
48
+ return;
49
+ }
50
+ // Fire-and-forget. Handler errors are caught and logged but the
51
+ // cron row still gets marked fired so we don't stack up retries.
52
+ try {
53
+ const result = handler();
54
+ if (result && typeof result.catch === 'function') {
55
+ ;
56
+ result.catch((err) => logger.error({ err, handler: handlerName }, 'internal cron handler rejected'));
57
+ }
58
+ }
59
+ catch (err) {
60
+ logger.error({ err, handler: handlerName }, 'internal cron handler threw');
61
+ }
62
+ }
31
63
  function dispatchOutbound(row, payload) {
32
64
  if (!isOutboundPayload(payload)) {
33
65
  logger.error({ id: row.id, payload }, 'cron outbound payload malformed');
@@ -0,0 +1,25 @@
1
+ // In-process handler registry for the 'internal' cron target.
2
+ //
3
+ // Not every periodic task fits the queue→worker model. Things like
4
+ // "run the journal observer sweep" or "regenerate compressed memory"
5
+ // are pure in-process work — there's no queue to enqueue into, the
6
+ // orchestrator just needs to call a function.
7
+ //
8
+ // Mechanism: cron rows with enqueue_into='internal' carry a payload
9
+ // with `handler: <name>`. The dispatcher looks up the name in this
10
+ // registry and invokes the function. Registry is populated at boot,
11
+ // before the orchestrator starts polling.
12
+ import { logger } from '../logger.js';
13
+ const registry = new Map();
14
+ export function registerInternalCronHandler(name, handler) {
15
+ if (registry.has(name)) {
16
+ logger.warn({ name }, 'internal cron handler already registered; overwriting');
17
+ }
18
+ registry.set(name, handler);
19
+ }
20
+ export function getInternalCronHandler(name) {
21
+ return registry.get(name);
22
+ }
23
+ export function listInternalCronHandlers() {
24
+ return [...registry.keys()];
25
+ }
@@ -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.4",
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",