@c4t4/heyamigo 0.9.14 → 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.
@@ -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.
@@ -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: {
@@ -19,6 +19,8 @@ const TAG_NAMES = [
19
19
  'ASYNC',
20
20
  'ASYNC-BROWSER',
21
21
  'SEND-TEXT',
22
+ 'CRON',
23
+ 'REMIND',
22
24
  ];
23
25
  const RoleSchema = z.object({
24
26
  description: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
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",