@c4t4/heyamigo 0.1.17 → 0.3.0
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/config/memory-instructions.md +81 -0
- package/dist/cli/index.js +6 -1
- package/dist/gateway/commands.js +195 -0
- package/dist/gateway/outgoing.js +39 -0
- package/dist/memory/digest-flag.js +76 -5
- package/dist/memory/journal-cadence.js +120 -0
- package/dist/memory/journal-nudger.js +282 -0
- package/dist/memory/journal-observer.js +225 -0
- package/dist/memory/journals.js +314 -0
- package/dist/memory/preamble.js +12 -1
- package/dist/memory/scheduler.js +36 -1
- package/dist/queue/worker.js +62 -5
- package/dist/wa/whitelist.js +8 -3
- package/package.json +1 -1
|
@@ -23,6 +23,87 @@ Do NOT flag for:
|
|
|
23
23
|
|
|
24
24
|
The marker will be stripped from your reply before the person sees it. It is a private signal to trigger profile/brief updates.
|
|
25
25
|
|
|
26
|
+
## Journals
|
|
27
|
+
|
|
28
|
+
A **journal** is a long-running tracking project the owner sets up (e.g. a health journal, a dog-training log, a work-wins log). Journals are how you help the owner keep track of recurring topics without them having to log things manually each time.
|
|
29
|
+
|
|
30
|
+
The list of the owner's active journals appears in your preamble under `[Journals: active]` with the slug and a short purpose line. Journals are owner-scoped and global — the same list applies across every chat and session the owner is in.
|
|
31
|
+
|
|
32
|
+
### When to flag a journal entry
|
|
33
|
+
|
|
34
|
+
When a message contains info that belongs in one of the active journals, append a marker to the END of your reply:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
[JOURNAL:<slug> — <one-line note>]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You can include multiple journal tags in one reply if multiple journals are relevant. You can combine `[DIGEST: ...]` and `[JOURNAL: ...]` in the same reply — they are independent. Order doesn't matter as long as all tags are at the end.
|
|
41
|
+
|
|
42
|
+
Separator between slug and note can be em-dash, en-dash, hyphen, or colon.
|
|
43
|
+
|
|
44
|
+
Examples (assuming `health` and `training` are active slugs):
|
|
45
|
+
|
|
46
|
+
- Owner: "slept 5hrs, mild headache again"
|
|
47
|
+
Reply ends with: `[JOURNAL:health — 5hrs sleep, mild headache]`
|
|
48
|
+
|
|
49
|
+
- Owner: "Biscuit finally learned 'stay' for 30 seconds today!"
|
|
50
|
+
Reply ends with: `[JOURNAL:training — Biscuit held 'stay' for 30s]`
|
|
51
|
+
|
|
52
|
+
- Owner: "slept well, 8hrs, and Biscuit did great on the walk"
|
|
53
|
+
Reply ends with: `[JOURNAL:health — 8hrs sleep, rested] [JOURNAL:training — good leash walk]`
|
|
54
|
+
|
|
55
|
+
### Hard rules
|
|
56
|
+
|
|
57
|
+
- **Only use slugs that appear in `[Journals: active]`.** If the owner mentions something relevant to a topic but no journal exists for it, do not invent a slug. Suggest creating a journal instead.
|
|
58
|
+
- **Don't flag unrelated content.** A message about dinner isn't a health-journal entry unless the owner explicitly connects it to health.
|
|
59
|
+
- **Don't flag every message.** Flag when there's real content for the journal. Chit-chat is not an entry.
|
|
60
|
+
- **Don't invent entries.** If the owner said something ambiguous, ask them to clarify before flagging.
|
|
61
|
+
|
|
62
|
+
### Proactive engagement in-conversation
|
|
63
|
+
|
|
64
|
+
Journals exist to keep the owner engaged over time. When you're responding and a journal is relevant, you may:
|
|
65
|
+
|
|
66
|
+
- Ask a clarifying follow-up if an entry is vague ("how bad was the headache, 1-10?").
|
|
67
|
+
- Reference a recent entry when useful ("last time you logged 5hrs sleep you also had a headache, same pattern?").
|
|
68
|
+
- Offer to check in: "want me to ask about sleep tomorrow night?"
|
|
69
|
+
|
|
70
|
+
Don't spam. One small nudge at a time, natural to the conversation. Never drag journal topics into a thread about something unrelated. Scheduled check-ins are handled separately by the system; your role here is in-conversation.
|
|
71
|
+
|
|
72
|
+
### Setting up a new journal
|
|
73
|
+
|
|
74
|
+
When the owner says something like "start a health journal":
|
|
75
|
+
|
|
76
|
+
1. Propose a concrete purpose in one short message:
|
|
77
|
+
> "Health journal: tracking sleep, symptoms, meds, mood. Daily check-in at 21:00, nudge if silent 3 days. Sound right?"
|
|
78
|
+
2. Wait for confirmation or edits.
|
|
79
|
+
3. Once confirmed, create it by appending this marker to the END of your reply:
|
|
80
|
+
```
|
|
81
|
+
[JOURNAL-NEW:<slug> — <one-line purpose>]
|
|
82
|
+
```
|
|
83
|
+
The marker will be stripped. The journal is created immediately and becomes active. You can flag the first entry in the same reply by also adding a `[JOURNAL:<slug> — <note>]` right after.
|
|
84
|
+
|
|
85
|
+
Example end of reply:
|
|
86
|
+
```
|
|
87
|
+
[JOURNAL-NEW:health — Track sleep, symptoms, meds, mood]
|
|
88
|
+
[JOURNAL:health — 5hrs sleep, mild headache]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Slug rules: lowercase letters, digits, hyphens. Max 48 chars. Must start with a letter or digit.
|
|
92
|
+
|
|
93
|
+
Be opinionated about the purpose. Don't ask ten questions; pick reasonable defaults and let them tweak.
|
|
94
|
+
|
|
95
|
+
### Pausing, resuming, archiving
|
|
96
|
+
|
|
97
|
+
The owner can also ask you to pause/archive/resume a journal. Emit one of these markers:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
[JOURNAL-PAUSE:<slug>]
|
|
101
|
+
[JOURNAL-RESUME:<slug>]
|
|
102
|
+
[JOURNAL-ARCHIVE:<slug>]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Do this only when the owner asks. Never pause or archive a journal on your own judgment.
|
|
106
|
+
|
|
26
107
|
## Browser and screenshots
|
|
27
108
|
|
|
28
109
|
You have access to a Chrome browser via tools: browser_navigate, browser_take_screenshot, browser_snapshot, browser_click, browser_type, browser_evaluate, and more.
|
package/dist/cli/index.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, resolve } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
2
5
|
import { Command } from 'commander';
|
|
6
|
+
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
7
|
+
const pkgVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
|
|
3
8
|
const program = new Command();
|
|
4
9
|
program
|
|
5
10
|
.name('heyamigo')
|
|
6
11
|
.description('WhatsApp AI Bot powered by Claude')
|
|
7
|
-
.version(
|
|
12
|
+
.version(pkgVersion);
|
|
8
13
|
program
|
|
9
14
|
.command('setup')
|
|
10
15
|
.description('Run the setup wizard')
|
package/dist/gateway/commands.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { clearSession, getSessionInfo } from '../ai/sessions.js';
|
|
2
2
|
import { reloadSystemPrompt } from '../ai/claude.js';
|
|
3
3
|
import { config } from '../config.js';
|
|
4
|
+
import { createJournal, getJournal, isValidSlug, listJournals, readEntries, snoozeJournal, updateJournalStatus, } from '../memory/journals.js';
|
|
4
5
|
import { runDigestNow } from '../memory/scheduler.js';
|
|
5
6
|
import { sendText } from '../wa/sender.js';
|
|
6
7
|
export async function tryCommand(ctx) {
|
|
@@ -54,5 +55,199 @@ export async function tryCommand(ctx) {
|
|
|
54
55
|
}).catch(() => undefined);
|
|
55
56
|
return true;
|
|
56
57
|
}
|
|
58
|
+
if (cmd === 'journal' || cmd === 'journals') {
|
|
59
|
+
if (!isOwner(ctx.senderNumber)) {
|
|
60
|
+
await sendText(ctx.sock, ctx.jid, 'Journals are owner-only.', ctx.quoted);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
const rest = trimmed.slice(prefix.length + cmd.length).trim();
|
|
64
|
+
await handleJournalCmd(ctx, rest);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (cmd === 'snooze') {
|
|
68
|
+
if (!isOwner(ctx.senderNumber)) {
|
|
69
|
+
await sendText(ctx.sock, ctx.jid, 'Snooze is owner-only.', ctx.quoted);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
const rest = trimmed.slice(prefix.length + cmd.length).trim();
|
|
73
|
+
await handleSnoozeCmd(ctx, rest);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
57
76
|
return false;
|
|
58
77
|
}
|
|
78
|
+
async function handleSnoozeCmd(ctx, rest) {
|
|
79
|
+
const [slugRaw, durationRaw] = rest.split(/\s+/);
|
|
80
|
+
const slug = (slugRaw ?? '').toLowerCase();
|
|
81
|
+
const duration = (durationRaw ?? '24h').toLowerCase();
|
|
82
|
+
if (!slug) {
|
|
83
|
+
await sendText(ctx.sock, ctx.jid, 'Usage: /snooze <slug> [duration]\nDuration: e.g. 6h, 2d (default 24h)', ctx.quoted);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!getJournal(slug)) {
|
|
87
|
+
await sendText(ctx.sock, ctx.jid, `No journal "${slug}".`, ctx.quoted);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const secs = parseDuration(duration);
|
|
91
|
+
if (!secs) {
|
|
92
|
+
await sendText(ctx.sock, ctx.jid, `Bad duration "${duration}". Use formats like 6h, 2d, 30m.`, ctx.quoted);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const until = Math.floor(Date.now() / 1000) + secs;
|
|
96
|
+
snoozeJournal(slug, until);
|
|
97
|
+
await sendText(ctx.sock, ctx.jid, `Snoozed "${slug}" for ${duration}. No nudges until ${new Date(until * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC.`, ctx.quoted);
|
|
98
|
+
}
|
|
99
|
+
function parseDuration(raw) {
|
|
100
|
+
const m = raw.match(/^(\d+)\s*([mhd])$/);
|
|
101
|
+
if (!m)
|
|
102
|
+
return null;
|
|
103
|
+
const n = Number(m[1]);
|
|
104
|
+
const u = m[2];
|
|
105
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
106
|
+
return null;
|
|
107
|
+
if (u === 'm')
|
|
108
|
+
return n * 60;
|
|
109
|
+
if (u === 'h')
|
|
110
|
+
return n * 3600;
|
|
111
|
+
if (u === 'd')
|
|
112
|
+
return n * 86400;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
function isOwner(senderNumber) {
|
|
116
|
+
return !!config.owner.number && senderNumber === config.owner.number;
|
|
117
|
+
}
|
|
118
|
+
async function handleJournalCmd(ctx, rest) {
|
|
119
|
+
const [subRaw, ...argParts] = rest.split(/\s+/);
|
|
120
|
+
const sub = (subRaw ?? 'list').toLowerCase();
|
|
121
|
+
const args = argParts.join(' ').trim();
|
|
122
|
+
if (sub === 'list' || sub === '') {
|
|
123
|
+
const journals = listJournals();
|
|
124
|
+
if (journals.length === 0) {
|
|
125
|
+
await sendText(ctx.sock, ctx.jid, 'No journals yet. Create one with:\n/journal create <slug> <purpose>', ctx.quoted);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const lines = ['Journals:'];
|
|
129
|
+
for (const j of journals) {
|
|
130
|
+
lines.push(`- ${j.slug} [${j.status}]: ${j.purpose || j.name}`);
|
|
131
|
+
}
|
|
132
|
+
await sendText(ctx.sock, ctx.jid, lines.join('\n'), ctx.quoted);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (sub === 'create' || sub === 'new') {
|
|
136
|
+
const [slugRaw, ...purposeParts] = args.split(/\s+/);
|
|
137
|
+
const slug = (slugRaw ?? '').toLowerCase();
|
|
138
|
+
const purpose = purposeParts.join(' ').trim();
|
|
139
|
+
if (!slug || !purpose) {
|
|
140
|
+
await sendText(ctx.sock, ctx.jid, 'Usage: /journal create <slug> <purpose>\nExample: /journal create health Track sleep, symptoms, meds, mood', ctx.quoted);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!isValidSlug(slug)) {
|
|
144
|
+
await sendText(ctx.sock, ctx.jid, `Invalid slug "${slug}". Use lowercase letters, digits, hyphens. Max 48 chars.`, ctx.quoted);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (getJournal(slug)) {
|
|
148
|
+
await sendText(ctx.sock, ctx.jid, `Journal "${slug}" already exists.`, ctx.quoted);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const j = createJournal({
|
|
153
|
+
slug,
|
|
154
|
+
name: titleCase(slug),
|
|
155
|
+
purpose,
|
|
156
|
+
});
|
|
157
|
+
await sendText(ctx.sock, ctx.jid, `Journal "${j.slug}" created and active. I'll start tagging relevant entries. Use /journal show ${j.slug} to inspect.`, ctx.quoted);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
await sendText(ctx.sock, ctx.jid, `Create failed: ${err.message}`, ctx.quoted);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (sub === 'show' || sub === 'info') {
|
|
165
|
+
const slug = args.split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
166
|
+
const j = getJournal(slug);
|
|
167
|
+
if (!j) {
|
|
168
|
+
await sendText(ctx.sock, ctx.jid, `No journal "${slug}".`, ctx.quoted);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const lines = [
|
|
172
|
+
`${j.name} (${j.slug}) [${j.status}]`,
|
|
173
|
+
j.purpose,
|
|
174
|
+
];
|
|
175
|
+
if (j.fields.length)
|
|
176
|
+
lines.push(`Fields: ${j.fields.join(', ')}`);
|
|
177
|
+
if (j.cadence.checkin)
|
|
178
|
+
lines.push(`Check-in: ${j.cadence.checkin}`);
|
|
179
|
+
if (j.cadence.followup_after)
|
|
180
|
+
lines.push(`Follow-up after: ${j.cadence.followup_after}`);
|
|
181
|
+
if (j.cadence.nudge_if_silent)
|
|
182
|
+
lines.push(`Nudge if silent: ${j.cadence.nudge_if_silent}`);
|
|
183
|
+
const entries = readEntries(j.slug, 5);
|
|
184
|
+
if (entries.length) {
|
|
185
|
+
lines.push('', 'Recent entries:');
|
|
186
|
+
for (const e of entries) {
|
|
187
|
+
const d = new Date(e.ts * 1000)
|
|
188
|
+
.toISOString()
|
|
189
|
+
.slice(0, 16)
|
|
190
|
+
.replace('T', ' ');
|
|
191
|
+
lines.push(`- [${d}] ${e.note}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
lines.push('', '(no entries yet)');
|
|
196
|
+
}
|
|
197
|
+
await sendText(ctx.sock, ctx.jid, lines.join('\n'), ctx.quoted);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (sub === 'entries') {
|
|
201
|
+
const [slugRaw, nRaw] = args.split(/\s+/);
|
|
202
|
+
const slug = (slugRaw ?? '').toLowerCase();
|
|
203
|
+
const n = Math.max(1, Math.min(50, Number(nRaw) || 10));
|
|
204
|
+
if (!getJournal(slug)) {
|
|
205
|
+
await sendText(ctx.sock, ctx.jid, `No journal "${slug}".`, ctx.quoted);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const entries = readEntries(slug, n);
|
|
209
|
+
if (!entries.length) {
|
|
210
|
+
await sendText(ctx.sock, ctx.jid, `No entries in "${slug}" yet.`, ctx.quoted);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const lines = [`Last ${entries.length} entries in "${slug}":`];
|
|
214
|
+
for (const e of entries) {
|
|
215
|
+
const d = new Date(e.ts * 1000)
|
|
216
|
+
.toISOString()
|
|
217
|
+
.slice(0, 16)
|
|
218
|
+
.replace('T', ' ');
|
|
219
|
+
lines.push(`- [${d}] (${e.source}) ${e.note}`);
|
|
220
|
+
}
|
|
221
|
+
await sendText(ctx.sock, ctx.jid, lines.join('\n'), ctx.quoted);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (sub === 'pause' || sub === 'resume' || sub === 'archive' || sub === 'activate') {
|
|
225
|
+
const slug = args.split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
226
|
+
if (!getJournal(slug)) {
|
|
227
|
+
await sendText(ctx.sock, ctx.jid, `No journal "${slug}".`, ctx.quoted);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const status = sub === 'pause'
|
|
231
|
+
? 'paused'
|
|
232
|
+
: sub === 'archive'
|
|
233
|
+
? 'archived'
|
|
234
|
+
: 'active';
|
|
235
|
+
const updated = updateJournalStatus(slug, status);
|
|
236
|
+
await sendText(ctx.sock, ctx.jid, `Journal "${slug}" is now ${updated?.status}.`, ctx.quoted);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await sendText(ctx.sock, ctx.jid, [
|
|
240
|
+
'Journal commands:',
|
|
241
|
+
'/journal list',
|
|
242
|
+
'/journal create <slug> <purpose>',
|
|
243
|
+
'/journal show <slug>',
|
|
244
|
+
'/journal entries <slug> [n]',
|
|
245
|
+
'/journal pause|resume|archive <slug>',
|
|
246
|
+
].join('\n'), ctx.quoted);
|
|
247
|
+
}
|
|
248
|
+
function titleCase(slug) {
|
|
249
|
+
return slug
|
|
250
|
+
.split('-')
|
|
251
|
+
.map((p) => (p ? p[0].toUpperCase() + p.slice(1) : p))
|
|
252
|
+
.join(' ');
|
|
253
|
+
}
|
package/dist/gateway/outgoing.js
CHANGED
|
@@ -107,6 +107,45 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
107
107
|
function sleep(ms) {
|
|
108
108
|
return new Promise((r) => setTimeout(r, ms));
|
|
109
109
|
}
|
|
110
|
+
// Proactive outbound: send a message to a chat without an incoming trigger.
|
|
111
|
+
// Chunks, persists to the message log, never throws. Callers are responsible
|
|
112
|
+
// for the canSendProactive() gate — this function does not re-check it.
|
|
113
|
+
export async function initiate(params) {
|
|
114
|
+
const sock = getSocket();
|
|
115
|
+
if (!sock) {
|
|
116
|
+
logger.warn({ jid: params.jid }, 'initiate: no socket available');
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const raw = params.text.replaceAll('—', ', ').replaceAll('–', '-');
|
|
120
|
+
if (!raw.trim())
|
|
121
|
+
return false;
|
|
122
|
+
try {
|
|
123
|
+
const chunks = chunkText(raw, config.reply.chunkChars);
|
|
124
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
125
|
+
const chunk = chunks[i];
|
|
126
|
+
await sendText(sock, params.jid, chunk);
|
|
127
|
+
await append({
|
|
128
|
+
id: `initiate-${Date.now()}-${i}`,
|
|
129
|
+
jid: params.jid,
|
|
130
|
+
direction: 'out',
|
|
131
|
+
fromMe: true,
|
|
132
|
+
sender: sock.user?.id ?? '',
|
|
133
|
+
senderNumber: config.owner.number,
|
|
134
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
135
|
+
text: chunk,
|
|
136
|
+
messageType: 'conversation',
|
|
137
|
+
});
|
|
138
|
+
if (i < chunks.length - 1)
|
|
139
|
+
await sleep(config.reply.chunkDelayMs);
|
|
140
|
+
}
|
|
141
|
+
logger.info({ jid: params.jid, chars: raw.length }, 'proactive message sent');
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
logger.error({ err, jid: params.jid }, 'initiate failed');
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
110
149
|
export function chunkText(text, maxChars) {
|
|
111
150
|
if (text.length <= maxChars)
|
|
112
151
|
return [text];
|
|
@@ -1,8 +1,79 @@
|
|
|
1
|
-
const
|
|
1
|
+
const TRAILING_TAG_RE = /\[(DIGEST|JOURNAL|JOURNAL-NEW|JOURNAL-PAUSE|JOURNAL-RESUME|JOURNAL-ARCHIVE):\s*([^\]]+)\]\s*$/i;
|
|
2
|
+
// Peel trailing tags off the end of a reply. Supported:
|
|
3
|
+
// [DIGEST: <reason>]
|
|
4
|
+
// [JOURNAL:<slug> — <note>] (append entry)
|
|
5
|
+
// [JOURNAL-NEW:<slug> — <purpose>] (create journal)
|
|
6
|
+
// [JOURNAL-PAUSE:<slug>]
|
|
7
|
+
// [JOURNAL-RESUME:<slug>]
|
|
8
|
+
// [JOURNAL-ARCHIVE:<slug>]
|
|
9
|
+
// Multiple tags are supported and can appear in any order at the tail.
|
|
10
|
+
// Tags must be the LAST thing in the reply (after trimming trailing whitespace).
|
|
11
|
+
export function extractFlags(reply) {
|
|
12
|
+
let current = reply;
|
|
13
|
+
let digest = null;
|
|
14
|
+
const journals = [];
|
|
15
|
+
const lifecycleOps = [];
|
|
16
|
+
while (true) {
|
|
17
|
+
const trimmed = current.replace(/\s+$/, '');
|
|
18
|
+
const match = trimmed.match(TRAILING_TAG_RE);
|
|
19
|
+
if (!match)
|
|
20
|
+
break;
|
|
21
|
+
const kind = match[1].toUpperCase();
|
|
22
|
+
const payload = (match[2] ?? '').trim();
|
|
23
|
+
if (kind === 'DIGEST') {
|
|
24
|
+
if (digest === null)
|
|
25
|
+
digest = payload;
|
|
26
|
+
}
|
|
27
|
+
else if (kind === 'JOURNAL') {
|
|
28
|
+
const parsed = parseJournalPayload(payload);
|
|
29
|
+
if (parsed)
|
|
30
|
+
journals.unshift(parsed);
|
|
31
|
+
}
|
|
32
|
+
else if (kind === 'JOURNAL-NEW') {
|
|
33
|
+
const parsed = parseJournalPayload(payload);
|
|
34
|
+
if (parsed) {
|
|
35
|
+
lifecycleOps.unshift({
|
|
36
|
+
kind: 'new',
|
|
37
|
+
slug: parsed.slug,
|
|
38
|
+
purpose: parsed.note,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (kind === 'JOURNAL-PAUSE' ||
|
|
43
|
+
kind === 'JOURNAL-RESUME' ||
|
|
44
|
+
kind === 'JOURNAL-ARCHIVE') {
|
|
45
|
+
const slug = payload.trim().toLowerCase();
|
|
46
|
+
if (/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
47
|
+
const op = kind === 'JOURNAL-PAUSE'
|
|
48
|
+
? 'pause'
|
|
49
|
+
: kind === 'JOURNAL-RESUME'
|
|
50
|
+
? 'resume'
|
|
51
|
+
: 'archive';
|
|
52
|
+
lifecycleOps.unshift({ kind: op, slug });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
current = trimmed.slice(0, match.index).trimEnd();
|
|
56
|
+
}
|
|
57
|
+
return { clean: current, digest, journals, lifecycleOps };
|
|
58
|
+
}
|
|
59
|
+
// Legacy helper kept so existing callers still compile.
|
|
2
60
|
export function extractDigestFlag(reply) {
|
|
3
|
-
const
|
|
61
|
+
const r = extractFlags(reply);
|
|
62
|
+
return { clean: r.clean, flag: r.digest };
|
|
63
|
+
}
|
|
64
|
+
const JOURNAL_SEP_RE = /\s*(?:[—\-–]|:)\s*/;
|
|
65
|
+
function parseJournalPayload(payload) {
|
|
66
|
+
// Split on first em-dash, en-dash, hyphen, or colon between slug and note.
|
|
67
|
+
const match = payload.match(/^([a-zA-Z0-9][a-zA-Z0-9-]*)(.*)$/);
|
|
4
68
|
if (!match)
|
|
5
|
-
return
|
|
6
|
-
const
|
|
7
|
-
|
|
69
|
+
return null;
|
|
70
|
+
const slug = match[1].toLowerCase();
|
|
71
|
+
const rest = match[2] ?? '';
|
|
72
|
+
const sepMatch = rest.match(JOURNAL_SEP_RE);
|
|
73
|
+
if (!sepMatch || sepMatch.index !== 0)
|
|
74
|
+
return null;
|
|
75
|
+
const note = rest.slice(sepMatch[0].length).trim();
|
|
76
|
+
if (!note)
|
|
77
|
+
return null;
|
|
78
|
+
return { slug, note };
|
|
8
79
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Cadence parsing and "is due?" evaluation for journals.
|
|
2
|
+
//
|
|
3
|
+
// Supported shapes:
|
|
4
|
+
// "daily HH:MM" — daily at HH:MM in owner.timezone (e.g. "daily 21:00")
|
|
5
|
+
// "Xh" — every X hours (e.g. "24h")
|
|
6
|
+
// "Xd" — every X days (e.g. "3d")
|
|
7
|
+
// "Xm" — every X minutes (only for testing; rounded up)
|
|
8
|
+
//
|
|
9
|
+
// Quiet hours shape: "HH:MM-HH:MM" (may span midnight: "22:00-08:00")
|
|
10
|
+
export function parseCadence(raw) {
|
|
11
|
+
if (!raw)
|
|
12
|
+
return null;
|
|
13
|
+
const s = raw.trim().toLowerCase();
|
|
14
|
+
const daily = s.match(/^daily\s+(\d{1,2}):(\d{2})$/);
|
|
15
|
+
if (daily) {
|
|
16
|
+
const hour = Number(daily[1]);
|
|
17
|
+
const minute = Number(daily[2]);
|
|
18
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
|
|
19
|
+
return null;
|
|
20
|
+
return { kind: 'daily', hour, minute };
|
|
21
|
+
}
|
|
22
|
+
const iv = s.match(/^(\d+)\s*([mhd])$/);
|
|
23
|
+
if (iv) {
|
|
24
|
+
const n = Number(iv[1]);
|
|
25
|
+
const unit = iv[2];
|
|
26
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
27
|
+
return null;
|
|
28
|
+
const secs = unit === 'm' ? n * 60 : unit === 'h' ? n * 3600 : n * 86400;
|
|
29
|
+
return { kind: 'interval', seconds: secs };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
// Returns unix seconds (ts) of the next scheduled firing AFTER the given
|
|
34
|
+
// "lastFiredTs" (or since "now" if never fired). For daily cadences, the time
|
|
35
|
+
// is computed in the owner's timezone.
|
|
36
|
+
export function nextFireTs(params) {
|
|
37
|
+
const { cadence, lastFiredTs, now, timezone } = params;
|
|
38
|
+
if (cadence.kind === 'interval') {
|
|
39
|
+
const base = lastFiredTs ?? now;
|
|
40
|
+
return base + cadence.seconds;
|
|
41
|
+
}
|
|
42
|
+
// daily HH:MM in timezone
|
|
43
|
+
const anchor = lastFiredTs ?? now;
|
|
44
|
+
// Start from the day of anchor, then walk forward until target time > anchor.
|
|
45
|
+
let target = dailyTargetTs(anchor, cadence, timezone);
|
|
46
|
+
while (target <= anchor) {
|
|
47
|
+
target = dailyTargetTs(target + 1, cadence, timezone);
|
|
48
|
+
}
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
// For a given reference ts, compute the unix ts for HH:MM that same day in the
|
|
52
|
+
// given timezone. May be earlier than ref if the clock time is past HH:MM.
|
|
53
|
+
function dailyTargetTs(refTs, cadence, timezone) {
|
|
54
|
+
const parts = timezoneParts(refTs, timezone);
|
|
55
|
+
// Construct an ISO-like string for "that date at HH:MM in tz" and convert
|
|
56
|
+
// back to epoch via UTC offset derived from parts.
|
|
57
|
+
const y = parts.year;
|
|
58
|
+
const mo = parts.month;
|
|
59
|
+
const d = parts.day;
|
|
60
|
+
const ts = zonedDateTimeToEpoch(y, mo, d, cadence.hour, cadence.minute, timezone);
|
|
61
|
+
return ts;
|
|
62
|
+
}
|
|
63
|
+
export function timezoneParts(tsSeconds, timezone) {
|
|
64
|
+
const fmt = new Intl.DateTimeFormat('en-GB', {
|
|
65
|
+
timeZone: timezone,
|
|
66
|
+
year: 'numeric',
|
|
67
|
+
month: '2-digit',
|
|
68
|
+
day: '2-digit',
|
|
69
|
+
hour: '2-digit',
|
|
70
|
+
minute: '2-digit',
|
|
71
|
+
second: '2-digit',
|
|
72
|
+
hour12: false,
|
|
73
|
+
});
|
|
74
|
+
const p = Object.fromEntries(fmt.formatToParts(new Date(tsSeconds * 1000)).map((x) => [x.type, x.value]));
|
|
75
|
+
return {
|
|
76
|
+
year: Number(p.year),
|
|
77
|
+
month: Number(p.month),
|
|
78
|
+
day: Number(p.day),
|
|
79
|
+
hour: Number(p.hour) % 24,
|
|
80
|
+
minute: Number(p.minute),
|
|
81
|
+
second: Number(p.second),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Convert a local (zoned) date-time to epoch seconds. Uses Intl to derive the
|
|
85
|
+
// UTC offset for that zone at that wall time.
|
|
86
|
+
function zonedDateTimeToEpoch(year, month, day, hour, minute, timezone) {
|
|
87
|
+
// Start with the wall time treated as UTC, then correct by the tz offset.
|
|
88
|
+
const asUtcMs = Date.UTC(year, month - 1, day, hour, minute, 0);
|
|
89
|
+
// Get what that UTC moment looks like in the target timezone
|
|
90
|
+
const parts = timezoneParts(Math.floor(asUtcMs / 1000), timezone);
|
|
91
|
+
// Compute the diff between the wall time we wanted and what we got
|
|
92
|
+
const wantedMs = asUtcMs;
|
|
93
|
+
const gotMs = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, 0);
|
|
94
|
+
const offsetMs = gotMs - wantedMs;
|
|
95
|
+
return Math.floor((asUtcMs - offsetMs) / 1000);
|
|
96
|
+
}
|
|
97
|
+
// Quiet hours: is the given time inside the quiet-hours window (in tz)?
|
|
98
|
+
// Accepts "HH:MM-HH:MM" (e.g. "22:00-08:00" = 10pm to 8am next day).
|
|
99
|
+
export function isInQuietHours(params) {
|
|
100
|
+
const { now, window, timezone } = params;
|
|
101
|
+
if (!window)
|
|
102
|
+
return false;
|
|
103
|
+
const m = window.match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/);
|
|
104
|
+
if (!m)
|
|
105
|
+
return false;
|
|
106
|
+
const startH = Number(m[1]);
|
|
107
|
+
const startM = Number(m[2]);
|
|
108
|
+
const endH = Number(m[3]);
|
|
109
|
+
const endM = Number(m[4]);
|
|
110
|
+
const parts = timezoneParts(now, timezone);
|
|
111
|
+
const curMin = parts.hour * 60 + parts.minute;
|
|
112
|
+
const startMin = startH * 60 + startM;
|
|
113
|
+
const endMin = endH * 60 + endM;
|
|
114
|
+
if (startMin <= endMin) {
|
|
115
|
+
// Non-wrapping window: [start, end)
|
|
116
|
+
return curMin >= startMin && curMin < endMin;
|
|
117
|
+
}
|
|
118
|
+
// Wrapping window: [start, 24:00) ∪ [00:00, end)
|
|
119
|
+
return curMin >= startMin || curMin < endMin;
|
|
120
|
+
}
|