@c4t4/heyamigo 0.9.12 → 0.9.14

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.
@@ -138,6 +138,17 @@ async function sendOne(sock, jid, msg) {
138
138
  }
139
139
  export const baileysAdapter = {
140
140
  channel: 'wa',
141
+ async sendTyping(externalId, state) {
142
+ const sock = activeSocket;
143
+ if (!sock)
144
+ return; // silently no-op if disconnected
145
+ try {
146
+ await sock.sendPresenceUpdate(state, externalId);
147
+ }
148
+ catch {
149
+ // typing is a UX hint; swallow errors
150
+ }
151
+ },
141
152
  async send(externalId, msg) {
142
153
  const sock = requireSocket();
143
154
  let sent;
package/dist/config.js CHANGED
@@ -90,6 +90,12 @@ const ConfigSchema = z.object({
90
90
  // Default 25MB matches WhatsApp's published per-message media limit
91
91
  // for most kinds. Set to null to disable the check.
92
92
  maxOutboundMediaBytes: z.number().int().positive().nullable().default(25 * 1024 * 1024),
93
+ // Send a quick acknowledgement when an incoming message has media.
94
+ // Bridge for the typing-indicator regression in Phase 4 — without
95
+ // this, users wait silently while the chat worker processes the
96
+ // image. Set false to disable.
97
+ ackOnMedia: z.boolean().default(true),
98
+ mediaAckText: z.string().default('looking…'),
93
99
  }),
94
100
  storage: z.object({
95
101
  messagesDir: z.string(),
@@ -8,6 +8,7 @@ import { config } from '../config.js';
8
8
  import { logger } from '../logger.js';
9
9
  import { buildMemoryPreamble } from '../memory/preamble.js';
10
10
  import { enqueueInbound } from '../queue/inbound.js';
11
+ import { enqueueOutbound } from '../queue/outbound.js';
11
12
  import { detectMediaType, downloadAndSave, getMediaSize, mediaPromptTag, } from '../store/media.js';
12
13
  import { append } from '../store/messages.js';
13
14
  import { getDailyTokens } from '../store/usage.js';
@@ -215,6 +216,19 @@ async function processMessages(messages, sock, ownerJid, isHistorySync = false)
215
216
  const actorPersonId = senderAddress
216
217
  ? personIdForAddress(senderAddress)
217
218
  : null;
219
+ // For media-bearing messages, send an immediate "looking…" ack
220
+ // via outbound so the user isn't left wondering whether the bot
221
+ // saw the image (typing indicator was dropped in Phase 4 —
222
+ // followup commit will reinstate via ChannelAdapter.sendTyping).
223
+ // The chat worker still processes the actual reply normally.
224
+ if (media && config.reply.ackOnMedia !== false) {
225
+ enqueueOutbound({
226
+ address: chatAddress,
227
+ kind: 'text',
228
+ text: config.reply.mediaAckText,
229
+ idempotencyKey: `media-ack-${msg.key.id}`,
230
+ });
231
+ }
218
232
  enqueueInbound({
219
233
  address: chatAddress,
220
234
  actorAddress: senderAddress,
@@ -75,11 +75,12 @@ async function sweep() {
75
75
  logger.error({ err }, 'journal observer sweep failed');
76
76
  }
77
77
  }
78
- let sweepTimer = null;
79
78
  const NUDGE_TICK_MS = 5 * 60 * 1000; // 5 minutes
79
+ let started = false;
80
80
  export function startScheduler() {
81
- if (sweepTimer)
81
+ if (started)
82
82
  return;
83
+ started = true;
83
84
  ensureScaffold();
84
85
  void prunePrompts(); // run once on boot
85
86
  // Rebuild the compressed memory view on boot so every session starts with
@@ -93,9 +94,23 @@ export function startScheduler() {
93
94
  logger.error({ err }, 'compressed: boot rebuild failed');
94
95
  }
95
96
  })();
96
- sweepTimer = setInterval(() => {
97
- void sweep().catch((err) => logger.error({ err }, 'sweep failed'));
98
- }, config.memory.sweepIntervalMs);
97
+ // Memory sweep: migrated from setInterval to a cron entry. Same
98
+ // cadence (config.memory.sweepIntervalMs); body runs as an internal
99
+ // cron handler so the orchestrator drives the schedule.
100
+ registerInternalCronHandler('memory-sweep', async () => {
101
+ try {
102
+ await sweep();
103
+ }
104
+ catch (err) {
105
+ logger.error({ err }, 'sweep failed');
106
+ }
107
+ });
108
+ enqueueCron({
109
+ name: 'memory-sweep',
110
+ enqueueInto: 'internal',
111
+ payload: { handler: 'memory-sweep' },
112
+ recurrence: `@every ${Math.floor(config.memory.sweepIntervalMs / 1000)}s`,
113
+ });
99
114
  // Proactive journal nudges (check-ins, silent-nudges). Migrated from
100
115
  // setInterval to a cron row → orchestrator. Same cadence, same body;
101
116
  // benefits are: survives restarts, visible in `crons` table, can be
@@ -122,17 +137,13 @@ async function runNudgeTickSafe() {
122
137
  }
123
138
  }
124
139
  export function stopScheduler() {
125
- if (sweepTimer) {
126
- clearInterval(sweepTimer);
127
- sweepTimer = null;
128
- }
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.
140
+ // All recurring work is now in the crons table; orchestrator handles
141
+ // its own shutdown. Just clear the in-process debounce timers (for
142
+ // scheduleDigest's per-jid coalescing).
133
143
  for (const t of pendingTimers.values())
134
144
  clearTimeout(t);
135
145
  pendingTimers.clear();
146
+ started = false;
136
147
  }
137
148
  // Exported for callers (CLI, /nudge command) that want to surgically
138
149
  // disable nudges without editing config. Use `setCronEnabled` from
@@ -15,8 +15,10 @@
15
15
  // nothing breaks.
16
16
  import { hostname } from 'os';
17
17
  import { eq } from 'drizzle-orm';
18
+ import { getChannelAdapter } from '../channels/index.js';
18
19
  import { config } from '../config.js';
19
20
  import { getDb } from '../db/index.js';
21
+ import { parseAddress } from '../db/address.js';
20
22
  import { workers } from '../db/schema.js';
21
23
  import { handleReply } from '../gateway/outgoing.js';
22
24
  import { logger } from '../logger.js';
@@ -87,11 +89,46 @@ function jobFromRow(row) {
87
89
  return null;
88
90
  }
89
91
  }
92
+ // Typing indicator. Fire-and-forget; UX nicety, never block real
93
+ // work. WA's presence-update expires ~15s, so we refresh every 10s
94
+ // for the duration of the job.
95
+ function startTyping(address) {
96
+ let parsed;
97
+ try {
98
+ parsed = parseAddress(address);
99
+ }
100
+ catch {
101
+ return () => undefined;
102
+ }
103
+ if (parsed.channel === 'system')
104
+ return () => undefined;
105
+ let adapter;
106
+ try {
107
+ adapter = getChannelAdapter(parsed.channel);
108
+ }
109
+ catch {
110
+ return () => undefined;
111
+ }
112
+ if (!adapter.sendTyping)
113
+ return () => undefined;
114
+ const externalId = parsed.externalId;
115
+ const fire = () => {
116
+ void adapter.sendTyping?.(externalId, 'composing').catch(() => undefined);
117
+ };
118
+ fire();
119
+ const interval = setInterval(fire, 10_000);
120
+ return () => {
121
+ clearInterval(interval);
122
+ void adapter.sendTyping?.(externalId, 'paused').catch(() => undefined);
123
+ };
124
+ }
90
125
  async function processOne(workerId, row) {
91
126
  setWorkerStatus(workerId, 'busy', `inbound:${row.id}`);
127
+ const stopTyping = startTyping(row.address);
92
128
  const job = jobFromRow(row);
93
129
  if (!job) {
94
130
  markInboundFailed(row.id, workerId, 'invalid payload');
131
+ stopTyping();
95
132
  setWorkerStatus(workerId, 'idle');
96
133
  return;
97
134
  }
@@ -134,6 +171,7 @@ async function processOne(workerId, row) {
134
171
  }
135
172
  }
136
173
  finally {
174
+ stopTyping();
137
175
  setWorkerStatus(workerId, 'idle');
138
176
  }
139
177
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
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",