@c4t4/heyamigo 0.9.13 → 0.9.15
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/channels/baileys.js +11 -0
- package/dist/memory/digest-flag.js +51 -0
- package/dist/memory/scheduler.js +24 -13
- package/dist/queue/chat-worker.js +38 -0
- package/dist/queue/worker.js +35 -1
- package/dist/wa/whitelist.js +2 -0
- package/package.json +1 -1
package/dist/channels/baileys.js
CHANGED
|
@@ -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;
|
|
@@ -15,6 +15,8 @@ const KINDS = [
|
|
|
15
15
|
'ASYNC',
|
|
16
16
|
'ASYNC-BROWSER',
|
|
17
17
|
'SEND-TEXT',
|
|
18
|
+
'CRON',
|
|
19
|
+
'REMIND',
|
|
18
20
|
];
|
|
19
21
|
// Walk backwards from the end of the string, tracking bracket depth, to find
|
|
20
22
|
// the `[` that matches the final `]`. Returns the tag kind, its payload, and
|
|
@@ -76,6 +78,8 @@ export function extractFlags(reply) {
|
|
|
76
78
|
const asyncTasks = [];
|
|
77
79
|
const asyncBrowserTasks = [];
|
|
78
80
|
const sendTexts = [];
|
|
81
|
+
const crons = [];
|
|
82
|
+
const reminds = [];
|
|
79
83
|
while (true) {
|
|
80
84
|
const peeled = peelTrailingTag(current);
|
|
81
85
|
if (!peeled)
|
|
@@ -112,6 +116,16 @@ export function extractFlags(reply) {
|
|
|
112
116
|
if (parsed)
|
|
113
117
|
sendTexts.unshift(parsed);
|
|
114
118
|
}
|
|
119
|
+
else if (kind === 'CRON') {
|
|
120
|
+
const parsed = parseCronPayload(payload);
|
|
121
|
+
if (parsed)
|
|
122
|
+
crons.unshift(parsed);
|
|
123
|
+
}
|
|
124
|
+
else if (kind === 'REMIND') {
|
|
125
|
+
const parsed = parseRemindPayload(payload);
|
|
126
|
+
if (parsed)
|
|
127
|
+
reminds.unshift(parsed);
|
|
128
|
+
}
|
|
115
129
|
}
|
|
116
130
|
return {
|
|
117
131
|
clean: current,
|
|
@@ -121,6 +135,8 @@ export function extractFlags(reply) {
|
|
|
121
135
|
asyncTasks,
|
|
122
136
|
asyncBrowserTasks,
|
|
123
137
|
sendTexts,
|
|
138
|
+
crons,
|
|
139
|
+
reminds,
|
|
124
140
|
};
|
|
125
141
|
}
|
|
126
142
|
// Strip flags that the sender's role isn't permitted to emit. The
|
|
@@ -138,6 +154,8 @@ export function filterFlagsByRole(flags, allowedTags) {
|
|
|
138
154
|
asyncTasks: allowed.has('ASYNC') ? flags.asyncTasks : [],
|
|
139
155
|
asyncBrowserTasks: allowed.has('ASYNC-BROWSER') ? flags.asyncBrowserTasks : [],
|
|
140
156
|
sendTexts: allowed.has('SEND-TEXT') ? flags.sendTexts : [],
|
|
157
|
+
crons: allowed.has('CRON') ? flags.crons : [],
|
|
158
|
+
reminds: allowed.has('REMIND') ? flags.reminds : [],
|
|
141
159
|
};
|
|
142
160
|
}
|
|
143
161
|
// Legacy helper kept so existing callers still compile.
|
|
@@ -146,6 +164,39 @@ export function extractDigestFlag(reply) {
|
|
|
146
164
|
return { clean: r.clean, flag: r.digest };
|
|
147
165
|
}
|
|
148
166
|
const JOURNAL_SEP_RE = /\s*(?:[—\-–]|:)\s*/;
|
|
167
|
+
// Parse `<recurrence> — <body>` payload. recurrence must start with
|
|
168
|
+
// '@' to match cron.ts's grammar (@every / @daily / @weekly).
|
|
169
|
+
function parseCronPayload(payload) {
|
|
170
|
+
const sepMatch = payload.match(/\s+[—–-]\s+/);
|
|
171
|
+
if (!sepMatch || sepMatch.index === undefined)
|
|
172
|
+
return null;
|
|
173
|
+
const recurrence = payload.slice(0, sepMatch.index).trim();
|
|
174
|
+
const body = payload.slice(sepMatch.index + sepMatch[0].length).trim();
|
|
175
|
+
if (!recurrence || !body)
|
|
176
|
+
return null;
|
|
177
|
+
if (!recurrence.startsWith('@'))
|
|
178
|
+
return null;
|
|
179
|
+
return { recurrence, body };
|
|
180
|
+
}
|
|
181
|
+
// Parse `in <n><unit> — <body>` payload. Supported units: s,m,h,d.
|
|
182
|
+
function parseRemindPayload(payload) {
|
|
183
|
+
const sepMatch = payload.match(/\s+[—–-]\s+/);
|
|
184
|
+
if (!sepMatch || sepMatch.index === undefined)
|
|
185
|
+
return null;
|
|
186
|
+
const timeSpec = payload.slice(0, sepMatch.index).trim();
|
|
187
|
+
const body = payload.slice(sepMatch.index + sepMatch[0].length).trim();
|
|
188
|
+
if (!timeSpec || !body)
|
|
189
|
+
return null;
|
|
190
|
+
const m = timeSpec.match(/^in\s+(\d+)\s*([smhd])$/i);
|
|
191
|
+
if (!m)
|
|
192
|
+
return null;
|
|
193
|
+
const n = parseInt(m[1], 10);
|
|
194
|
+
const unit = m[2].toLowerCase();
|
|
195
|
+
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
|
|
196
|
+
if (n <= 0)
|
|
197
|
+
return null;
|
|
198
|
+
return { whenSecondsFromNow: n * mult, body };
|
|
199
|
+
}
|
|
149
200
|
// Parse `address=<addr> body="..."` style key=value payload.
|
|
150
201
|
// Body is delimited by double quotes; everything else by whitespace.
|
|
151
202
|
// Returns null if address or body is missing.
|
package/dist/memory/scheduler.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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/dist/queue/worker.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { getProvider } from '../ai/providers.js';
|
|
2
2
|
import { clearSession, setSession, setUsage } from '../ai/sessions.js';
|
|
3
3
|
import { config } from '../config.js';
|
|
4
|
+
import { formatAddress, jidToAddress } from '../db/address.js';
|
|
4
5
|
import { logger } from '../logger.js';
|
|
5
6
|
import { addDailyTokens } from '../store/usage.js';
|
|
6
7
|
import { extractFlags, filterFlagsByRole } from '../memory/digest-flag.js';
|
|
7
8
|
import { isValidSlug } from '../memory/journals.js';
|
|
8
9
|
import { enqueueAsyncTask, enqueueBrowserTask } from './async-tasks.js';
|
|
10
|
+
import { enqueueCron } from './crons.js';
|
|
9
11
|
import { enqueueMemoryWrite } from './memory-writes.js';
|
|
10
12
|
import { enqueueOutbound } from './outbound.js';
|
|
11
13
|
function isStaleSessionError(err) {
|
|
@@ -41,7 +43,7 @@ async function callClaude(job) {
|
|
|
41
43
|
addDailyTokens(job.senderNumber, usage.inputTokens + usage.outputTokens);
|
|
42
44
|
}
|
|
43
45
|
const rawFlags = extractFlags(reply);
|
|
44
|
-
const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, sendTexts, } = filterFlagsByRole(rawFlags, job.allowedTags);
|
|
46
|
+
const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, sendTexts, crons, reminds, } = filterFlagsByRole(rawFlags, job.allowedTags);
|
|
45
47
|
// Detect any stripped tags so we can log + nudge the role config
|
|
46
48
|
// if a user is repeatedly hitting the gate.
|
|
47
49
|
const stripped = [];
|
|
@@ -141,6 +143,38 @@ async function callClaude(job) {
|
|
|
141
143
|
});
|
|
142
144
|
logger.info({ from: job.jid, to: t.address, chars: t.body.length }, 'SEND-TEXT enqueued');
|
|
143
145
|
}
|
|
146
|
+
// [CRON: @every X — body] and [REMIND: in Nu — body] create cron
|
|
147
|
+
// rows that fire into outbound at their scheduled time. The
|
|
148
|
+
// originating chat (job.jid) is the destination for both.
|
|
149
|
+
const chatAddress = formatAddress(jidToAddress(job.jid));
|
|
150
|
+
const cronBase = `chat-cron-${job.jid}-${Date.now()}`;
|
|
151
|
+
for (let i = 0; i < crons.length; i++) {
|
|
152
|
+
const c = crons[i];
|
|
153
|
+
try {
|
|
154
|
+
enqueueCron({
|
|
155
|
+
name: `${cronBase}-${i}`,
|
|
156
|
+
enqueueInto: 'outbound',
|
|
157
|
+
payload: { address: chatAddress, kind: 'text', text: c.body },
|
|
158
|
+
recurrence: c.recurrence,
|
|
159
|
+
});
|
|
160
|
+
logger.info({ jid: job.jid, recurrence: c.recurrence, chars: c.body.length }, 'CRON tag scheduled');
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
logger.warn({ err, jid: job.jid, recurrence: c.recurrence }, 'CRON tag failed (bad recurrence?)');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const remindBase = `chat-remind-${job.jid}-${Date.now()}`;
|
|
167
|
+
for (let i = 0; i < reminds.length; i++) {
|
|
168
|
+
const r = reminds[i];
|
|
169
|
+
enqueueCron({
|
|
170
|
+
name: `${remindBase}-${i}`,
|
|
171
|
+
enqueueInto: 'outbound',
|
|
172
|
+
payload: { address: chatAddress, kind: 'text', text: r.body },
|
|
173
|
+
recurrence: null,
|
|
174
|
+
firstRunAt: Math.floor(Date.now() / 1000) + r.whenSecondsFromNow,
|
|
175
|
+
});
|
|
176
|
+
logger.info({ jid: job.jid, inSeconds: r.whenSecondsFromNow, chars: r.body.length }, 'REMIND tag scheduled');
|
|
177
|
+
}
|
|
144
178
|
return {
|
|
145
179
|
reply: clean,
|
|
146
180
|
stats: {
|
package/dist/wa/whitelist.js
CHANGED