@c4t4/heyamigo 0.9.6 → 0.9.8

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/boot.js ADDED
@@ -0,0 +1,67 @@
1
+ // Shared bootstrap for both the library entry (src/index.ts) and the
2
+ // CLI start command (src/cli/start.ts). Single source of truth for
3
+ // startup order — there used to be two parallel main() functions that
4
+ // drifted; this prevents that.
5
+ import { setBaileysSocket } from './channels/index.js';
6
+ import { closeDb, initDb } from './db/index.js';
7
+ import { syncIdentitiesFromAccess } from './db/identity-sync.js';
8
+ import { attachIncoming } from './gateway/incoming.js';
9
+ import { logger } from './logger.js';
10
+ import { startScheduler } from './memory/scheduler.js';
11
+ import { startChatWorkers, stopChatWorkers } from './queue/chat-worker.js';
12
+ import { requestShutdown, startOrchestrator, stopOrchestrator, } from './queue/orchestrator.js';
13
+ import { startSenderWorker, stopSenderWorker } from './queue/sender-worker.js';
14
+ import { startSocket } from './wa/socket.js';
15
+ let booted = false;
16
+ export async function bootBot() {
17
+ if (booted) {
18
+ logger.warn('bootBot called twice; ignoring');
19
+ return;
20
+ }
21
+ booted = true;
22
+ logger.info('heyamigo starting');
23
+ // Migrations + drift check first. Refuses to start on schema mismatch
24
+ // — protects production data from a half-applied schema upgrade.
25
+ initDb();
26
+ // Derived view: persons + identities from access.json (idempotent).
27
+ syncIdentitiesFromAccess();
28
+ // Orchestrator first so workers see a control surface from the moment
29
+ // they register. Drain hook stops everything in reverse order.
30
+ startOrchestrator({
31
+ onShutdownDrained: () => {
32
+ stopChatWorkers();
33
+ stopSenderWorker();
34
+ stopOrchestrator();
35
+ closeDb();
36
+ },
37
+ });
38
+ // Workers next. Inbound table is the source of truth; anything left
39
+ // from a previous crash gets claimed by the new pool automatically.
40
+ // No separate replay step needed.
41
+ startSenderWorker();
42
+ startChatWorkers();
43
+ startScheduler();
44
+ await startSocket((sock) => {
45
+ attachIncoming(sock);
46
+ // Point the Baileys adapter at the live socket. Called on each
47
+ // reconnect with a fresh sock; the adapter just keeps the latest.
48
+ setBaileysSocket(sock);
49
+ });
50
+ }
51
+ // Install once. Both signals trigger the same graceful drain:
52
+ // orchestrator picks up the shutdown control row, waits for busy
53
+ // workers, then runs onShutdownDrained and exits.
54
+ let signalsInstalled = false;
55
+ export function installShutdownSignals() {
56
+ if (signalsInstalled)
57
+ return;
58
+ signalsInstalled = true;
59
+ process.on('SIGINT', () => {
60
+ logger.info('SIGINT received, requesting graceful shutdown');
61
+ requestShutdown('SIGINT');
62
+ });
63
+ process.on('SIGTERM', () => {
64
+ logger.info('SIGTERM received, requesting graceful shutdown');
65
+ requestShutdown('SIGTERM');
66
+ });
67
+ }
package/dist/cli/start.js CHANGED
@@ -1,10 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
- import { attachIncoming } from '../gateway/incoming.js';
3
- import { handleReply } from '../gateway/outgoing.js';
2
+ import { bootBot, installShutdownSignals } from '../boot.js';
4
3
  import { logger } from '../logger.js';
5
- import { startScheduler } from '../memory/scheduler.js';
6
- import { replayPending } from '../queue/queue.js';
7
- import { startSocket } from '../wa/socket.js';
8
4
  export async function main() {
9
5
  try {
10
6
  execSync('which claude', { stdio: 'pipe' });
@@ -14,23 +10,9 @@ export async function main() {
14
10
  ' npm install -g @anthropic-ai/claude-code\n');
15
11
  process.exit(1);
16
12
  }
17
- logger.info('heyamigo starting');
18
- startScheduler();
19
- await startSocket((sock) => {
20
- attachIncoming(sock);
21
- });
22
- void replayPending(async (job, result) => {
23
- await handleReply(job, result, {});
24
- }).catch((err) => logger.error({ err }, 'replay failed'));
13
+ installShutdownSignals();
14
+ await bootBot();
25
15
  }
26
- process.on('SIGINT', () => {
27
- logger.info('SIGINT received, shutting down');
28
- process.exit(0);
29
- });
30
- process.on('SIGTERM', () => {
31
- logger.info('SIGTERM received, shutting down');
32
- process.exit(0);
33
- });
34
16
  main().catch((err) => {
35
17
  logger.error({ err }, 'fatal error during boot');
36
18
  process.exit(1);
package/dist/config.js CHANGED
@@ -36,6 +36,14 @@ const ConfigSchema = z.object({
36
36
  outputFormat: z.enum(['json', 'text', 'stream-json']),
37
37
  contextWindow: z.number(),
38
38
  }),
39
+ chatPool: z
40
+ .object({
41
+ // How many chat workers run in parallel. Per-address
42
+ // serialization means N workers serve up to N different chats
43
+ // concurrently; per-chat ordering is preserved naturally.
44
+ size: z.number().int().positive().default(5),
45
+ })
46
+ .default({ size: 5 }),
39
47
  codex: z
40
48
  .object({
41
49
  // Optional model override. If unset, Codex uses its default. Passed
package/dist/db/schema.js CHANGED
@@ -159,6 +159,12 @@ export const inbound = sqliteTable('inbound', {
159
159
  mediaBytes: integer('media_bytes'),
160
160
  pushName: text('push_name'), // sender's display name at send time
161
161
  triggerReason: text('trigger_reason'), // 'alias'|'mention'|'reply'|'owner'|...
162
+ // Producer-built worker payload (JSON). Chat worker deserializes
163
+ // at claim time to reconstruct the Job. Keeps the rebuild logic
164
+ // out of the worker for Phase 4; later phases may move portions
165
+ // (memory preamble assembly) into the worker for fresh-at-claim
166
+ // semantics. Nullable so non-chat inserters can leave it off.
167
+ payload: text('payload'),
162
168
  // 'pending'|'claimed'|'done'|'failed'|'dlq'
163
169
  status: text('status').notNull(),
164
170
  attempts: integer('attempts').notNull().default(0),
@@ -59,5 +59,11 @@ export async function tryCommand(ctx) {
59
59
  }).catch(() => undefined);
60
60
  return true;
61
61
  }
62
+ if (cmd === 'queues') {
63
+ const { takeQueuesSnapshot, formatQueuesSnapshot } = await import('../queue/observability.js');
64
+ const snap = takeQueuesSnapshot();
65
+ await sendText(ctx.sock, ctx.jid, formatQueuesSnapshot(snap), ctx.quoted);
66
+ return true;
67
+ }
62
68
  return false;
63
69
  }
@@ -2,10 +2,12 @@ import { unlink } from 'fs/promises';
2
2
  import { getContentType, isJidGroup, jidDecode, jidNormalizedUser, } from 'baileys';
3
3
  import { getProvider } from '../ai/providers.js';
4
4
  import { getSession } from '../ai/sessions.js';
5
+ import { formatAddress, jidToAddress } from '../db/address.js';
6
+ import { personIdForAddress } from '../db/identity-sync.js';
5
7
  import { config } from '../config.js';
6
8
  import { logger } from '../logger.js';
7
9
  import { buildMemoryPreamble } from '../memory/preamble.js';
8
- import { enqueue } from '../queue/queue.js';
10
+ import { enqueueInbound } from '../queue/inbound.js';
9
11
  import { detectMediaType, downloadAndSave, getMediaSize, mediaPromptTag, } from '../store/media.js';
10
12
  import { append } from '../store/messages.js';
11
13
  import { getDailyTokens } from '../store/usage.js';
@@ -13,7 +15,6 @@ import { sendText } from '../wa/sender.js';
13
15
  import { checkAccess, discoverGroupIfNew, getLimitsForUser, getRoleForContext, } from '../wa/whitelist.js';
14
16
  import { buildInitPayload, buildRecentContext } from './bootstrap.js';
15
17
  import { tryCommand } from './commands.js';
16
- import { handleReply } from './outgoing.js';
17
18
  import { checkTrigger } from './triggers.js';
18
19
  export function attachIncoming(sock) {
19
20
  const ownerJid = sock.user?.id
@@ -201,46 +202,29 @@ async function processMessages(messages, sock, ownerJid, isHistorySync = false)
201
202
  fromMe: stored.fromMe,
202
203
  allowedTools: role.tools,
203
204
  };
204
- // Start typing indicator immediately; refresh every 10s (WA expires ~15s)
205
- let typingHeartbeat = null;
206
- if (config.reply.typingIndicator) {
207
- void sock
208
- .sendPresenceUpdate('composing', stored.jid)
209
- .catch(() => undefined);
210
- typingHeartbeat = setInterval(() => {
211
- void sock
212
- .sendPresenceUpdate('composing', stored.jid)
213
- .catch(() => undefined);
214
- }, 10000);
215
- }
216
- const stopTyping = () => {
217
- if (typingHeartbeat)
218
- clearInterval(typingHeartbeat);
219
- typingHeartbeat = null;
220
- };
221
- // Defense-in-depth: if nothing else clears the heartbeat within 10 min
222
- // (e.g. a code path forgot), force-stop. Prevents runaway "typing..."
223
- // indicators when the pipeline silently fails.
224
- const typingSafetyCap = setTimeout(() => {
225
- if (typingHeartbeat) {
226
- logger.warn({ jid: job.jid }, 'typingHeartbeat safety-cap fired, forcing clear');
227
- stopTyping();
228
- }
229
- }, 10 * 60 * 1000);
230
- typingSafetyCap.unref();
231
- enqueue(job)
232
- .then((result) => handleReply(job, result, msg))
233
- .catch((err) => {
234
- const isTimeout = err instanceof Error && err.name === 'ClaudeTimeoutError';
235
- logger.error({ err, jid: job.jid, isTimeout }, 'pipeline failed');
236
- const replyText = isTimeout
237
- ? 'That request timed out. The task was cancelled, queue is moving.'
238
- : config.reply.errorMessage;
239
- return handleReply(job, { reply: replyText }, msg).catch((e) => logger.error({ err: e, jid: job.jid }, 'failed to send error reply'));
240
- })
241
- .finally(() => {
242
- stopTyping();
243
- clearTimeout(typingSafetyCap);
205
+ // Enqueue into the inbound table; chat worker pool drains and
206
+ // calls processJob + handleReply asynchronously. Typing indicator
207
+ // is temporarily dropped (was tied to the old synchronous flow);
208
+ // re-add via ChannelAdapter.sendTyping() in a follow-up commit.
209
+ const chatAddress = formatAddress(jidToAddress(stored.jid));
210
+ const senderAddress = stored.senderNumber
211
+ ? formatAddress(jidToAddress(`${stored.senderNumber}@s.whatsapp.net`))
212
+ : null;
213
+ const personId = personIdForAddress(chatAddress);
214
+ const actorPersonId = senderAddress
215
+ ? personIdForAddress(senderAddress)
216
+ : null;
217
+ enqueueInbound({
218
+ address: chatAddress,
219
+ actorAddress: senderAddress,
220
+ personId,
221
+ actorPersonId,
222
+ externalMsgId: msg.key.id ?? null,
223
+ text: stored.text,
224
+ pushName: stored.pushName ?? null,
225
+ triggerReason,
226
+ receivedAt: stored.timestamp,
227
+ payload: job,
244
228
  });
245
229
  }
246
230
  catch (err) {
package/dist/index.js CHANGED
@@ -1,59 +1,7 @@
1
- import { setBaileysSocket } from './channels/index.js';
2
- import { closeDb, initDb } from './db/index.js';
3
- import { syncIdentitiesFromAccess } from './db/identity-sync.js';
4
- import { attachIncoming } from './gateway/incoming.js';
5
- import { handleReply } from './gateway/outgoing.js';
1
+ import { bootBot, installShutdownSignals } from './boot.js';
6
2
  import { logger } from './logger.js';
7
- import { startScheduler } from './memory/scheduler.js';
8
- import { requestShutdown, startOrchestrator, stopOrchestrator, } from './queue/orchestrator.js';
9
- import { replayPending } from './queue/queue.js';
10
- import { startSenderWorker, stopSenderWorker } from './queue/sender-worker.js';
11
- import { startSocket } from './wa/socket.js';
12
- async function main() {
13
- logger.info('heyamigo starting');
14
- // Migrations + drift check first; refuses to start on schema mismatch.
15
- initDb();
16
- // Derived view: populate persons + identities from access.json.
17
- syncIdentitiesFromAccess();
18
- // Orchestrator handles cross-cutting bookkeeping: control table
19
- // signals, stuck-claim reclaim, dead-worker detection, cron polling
20
- // (Phase 2.2+). Starts before workers so it can see them register.
21
- startOrchestrator({
22
- onShutdownDrained: () => {
23
- stopSenderWorker();
24
- stopOrchestrator();
25
- closeDb();
26
- },
27
- });
28
- // Sender worker drains outbound queue → channel adapters. Started
29
- // before the socket so it's ready when handleReply enqueues rows.
30
- startSenderWorker();
31
- startScheduler();
32
- await startSocket((sock) => {
33
- attachIncoming(sock);
34
- // Point the Baileys adapter at the live socket. Called on each
35
- // reconnect with a fresh sock; the adapter just keeps the latest.
36
- setBaileysSocket(sock);
37
- });
38
- // Replay any jobs left from a previous crash (no original WAMessage
39
- // available, so replies are sent as plain messages, not quoted).
40
- void replayPending(async (job, result) => {
41
- await handleReply(job, result, {});
42
- }).catch((err) => logger.error({ err }, 'replay failed'));
43
- }
44
- // Graceful shutdown: signal handler writes a 'shutdown' row to the
45
- // control table; orchestrator picks it up, drains in-flight work,
46
- // then runs onShutdownDrained (stops workers, closes DB) and exits.
47
- // A 30s timer inside the orchestrator force-exits if drain hangs.
48
- process.on('SIGINT', () => {
49
- logger.info('SIGINT received, requesting graceful shutdown');
50
- requestShutdown('SIGINT');
51
- });
52
- process.on('SIGTERM', () => {
53
- logger.info('SIGTERM received, requesting graceful shutdown');
54
- requestShutdown('SIGTERM');
55
- });
56
- main().catch((err) => {
3
+ installShutdownSignals();
4
+ bootBot().catch((err) => {
57
5
  logger.error({ err }, 'fatal error during boot');
58
6
  process.exit(1);
59
7
  });
@@ -0,0 +1,183 @@
1
+ // Chat worker pool. N workers drain the inbound queue; per-address
2
+ // serialization ensures one in-flight job per chat while different
3
+ // chats run in parallel (Phase 4's headline feature).
4
+ //
5
+ // Each worker:
6
+ // 1. claimNextInbound (atomic, serialized per address)
7
+ // 2. deserialize the producer-built Job payload
8
+ // 3. call processJob (existing AI + marker handling, unchanged)
9
+ // 4. call handleReply (enqueues outbound rows)
10
+ // 5. markInboundDone — or markInboundRetryOrDlq on error
11
+ //
12
+ // Replaces the in-memory fastq queue (queue/queue.ts). The old
13
+ // queue + persistence files stay in place for now in case they're
14
+ // still referenced anywhere; clean removal lands after we confirm
15
+ // nothing breaks.
16
+ import { hostname } from 'os';
17
+ import { eq } from 'drizzle-orm';
18
+ import { config } from '../config.js';
19
+ import { getDb } from '../db/index.js';
20
+ import { workers } from '../db/schema.js';
21
+ import { handleReply } from '../gateway/outgoing.js';
22
+ import { logger } from '../logger.js';
23
+ import { processJob } from './worker.js';
24
+ import { claimNextInbound, markInboundDone, markInboundFailed, markInboundRetryOrDlq, } from './inbound.js';
25
+ const HEARTBEAT_INTERVAL_MS = 5_000;
26
+ const IDLE_POLL_INTERVAL_MS = 250;
27
+ const BUSY_POLL_INTERVAL_MS = 0; // immediately try next after a successful claim
28
+ const activeWorkers = [];
29
+ let stopping = false;
30
+ let heartbeatTimer = null;
31
+ function newWorkerId(slot) {
32
+ return `${hostname()}-${process.pid}-chat-${slot}`;
33
+ }
34
+ function registerWorker(id) {
35
+ const db = getDb();
36
+ const now = Math.floor(Date.now() / 1000);
37
+ db.insert(workers)
38
+ .values({
39
+ id,
40
+ kind: 'chat',
41
+ status: 'idle',
42
+ currentJob: null,
43
+ lastSeen: now,
44
+ startedAt: now,
45
+ })
46
+ .onConflictDoUpdate({
47
+ target: workers.id,
48
+ set: { status: 'idle', currentJob: null, lastSeen: now, startedAt: now },
49
+ })
50
+ .run();
51
+ }
52
+ function setWorkerStatus(id, status, currentJob = null) {
53
+ const db = getDb();
54
+ db.update(workers)
55
+ .set({
56
+ status,
57
+ currentJob,
58
+ lastSeen: Math.floor(Date.now() / 1000),
59
+ })
60
+ .where(eq(workers.id, id))
61
+ .run();
62
+ }
63
+ function heartbeatAll() {
64
+ if (activeWorkers.length === 0)
65
+ return;
66
+ const db = getDb();
67
+ const now = Math.floor(Date.now() / 1000);
68
+ for (const id of activeWorkers) {
69
+ db.update(workers)
70
+ .set({ lastSeen: now })
71
+ .where(eq(workers.id, id))
72
+ .run();
73
+ }
74
+ }
75
+ // Reconstruct the Job from an inbound row's payload. The payload was
76
+ // built by the producer (gateway/incoming.ts) at enqueue time.
77
+ function jobFromRow(row) {
78
+ if (!row.payload) {
79
+ logger.warn({ id: row.id }, 'inbound row has no payload; cannot reconstruct Job');
80
+ return null;
81
+ }
82
+ try {
83
+ return JSON.parse(row.payload);
84
+ }
85
+ catch (err) {
86
+ logger.error({ err, id: row.id }, 'inbound row payload is not JSON');
87
+ return null;
88
+ }
89
+ }
90
+ async function processOne(workerId, row) {
91
+ setWorkerStatus(workerId, 'busy', `inbound:${row.id}`);
92
+ const job = jobFromRow(row);
93
+ if (!job) {
94
+ markInboundFailed(row.id, workerId, 'invalid payload');
95
+ setWorkerStatus(workerId, 'idle');
96
+ return;
97
+ }
98
+ try {
99
+ const result = await processJob(job);
100
+ // handleReply's third arg was the original WAMessage (used for
101
+ // group quoting). That regression was already deferred in Phase 1;
102
+ // pass an empty stub here too. The compiler type is loose enough.
103
+ await handleReply(job, result, {});
104
+ const ok = markInboundDone(row.id, workerId);
105
+ if (!ok) {
106
+ logger.warn({ id: row.id, workerId }, 'inbound markDone failed (claim lost?). reply was already enqueued.');
107
+ }
108
+ logger.info({
109
+ id: row.id,
110
+ address: row.address,
111
+ chars: result.reply.length,
112
+ dur: result.stats?.durationMs,
113
+ }, 'inbound processed');
114
+ }
115
+ catch (err) {
116
+ const isTimeout = err instanceof Error && err.name === 'ClaudeTimeoutError';
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ const result = markInboundRetryOrDlq(row.id, workerId, msg);
119
+ if (result.deadLettered) {
120
+ logger.error({ err, id: row.id, address: row.address, isTimeout }, 'inbound dead-lettered after max attempts');
121
+ // Send a user-facing failure ack so the chat isn't left hanging.
122
+ try {
123
+ const failText = isTimeout
124
+ ? 'That request timed out. The task was cancelled, queue is moving.'
125
+ : config.reply.errorMessage;
126
+ await handleReply(job, { reply: failText }, {});
127
+ }
128
+ catch (e) {
129
+ logger.error({ err: e, id: row.id }, 'failed to send DLQ-ack reply');
130
+ }
131
+ }
132
+ else if (result.retried) {
133
+ logger.warn({ err, id: row.id, address: row.address, isTimeout }, 'inbound transient fail, will retry');
134
+ }
135
+ }
136
+ finally {
137
+ setWorkerStatus(workerId, 'idle');
138
+ }
139
+ }
140
+ async function loop(workerId) {
141
+ while (!stopping) {
142
+ let processed = false;
143
+ try {
144
+ const row = claimNextInbound(workerId);
145
+ if (row) {
146
+ await processOne(workerId, row);
147
+ processed = true;
148
+ }
149
+ }
150
+ catch (err) {
151
+ logger.error({ err, workerId }, 'chat worker loop error');
152
+ }
153
+ const delay = processed ? BUSY_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
154
+ if (delay > 0) {
155
+ await new Promise((res) => setTimeout(res, delay));
156
+ }
157
+ else {
158
+ // Yield without sleeping so other microtasks (heartbeat etc) run.
159
+ await new Promise((res) => setImmediate(res));
160
+ }
161
+ }
162
+ setWorkerStatus(workerId, 'dead');
163
+ }
164
+ export function startChatWorkers() {
165
+ if (activeWorkers.length > 0) {
166
+ logger.warn('chat workers already started; ignoring');
167
+ return;
168
+ }
169
+ const pool = Math.max(1, config.chatPool?.size ?? 5);
170
+ for (let i = 0; i < pool; i++) {
171
+ const id = newWorkerId(i);
172
+ activeWorkers.push(id);
173
+ registerWorker(id);
174
+ void loop(id).catch((err) => logger.fatal({ err, workerId: id }, 'chat worker loop crashed'));
175
+ }
176
+ heartbeatTimer = setInterval(heartbeatAll, HEARTBEAT_INTERVAL_MS);
177
+ logger.info({ pool }, 'chat worker pool started');
178
+ }
179
+ export function stopChatWorkers() {
180
+ stopping = true;
181
+ if (heartbeatTimer)
182
+ clearInterval(heartbeatTimer);
183
+ }
@@ -37,6 +37,7 @@ export function enqueueInbound(input) {
37
37
  mediaBytes: input.mediaBytes ?? null,
38
38
  pushName: input.pushName ?? null,
39
39
  triggerReason: input.triggerReason ?? null,
40
+ payload: input.payload === undefined ? null : JSON.stringify(input.payload),
40
41
  status: 'pending',
41
42
  attempts: 0,
42
43
  nextAttemptAt: null,