@c4t4/heyamigo 0.9.25 → 0.10.1
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/README.md +37 -250
- package/config/memory-instructions.md +54 -5
- package/dist/config.js +23 -0
- package/dist/db/schema.js +55 -0
- package/dist/gateway/commands.js +13 -1
- package/dist/gateway/outgoing.js +12 -0
- package/dist/memory/digest-flag.js +222 -0
- package/dist/memory/journals.js +3 -24
- package/dist/memory/preamble.js +21 -0
- package/dist/memory/scheduler.js +6 -32
- package/dist/queue/thread-list.js +167 -0
- package/dist/queue/thread-weights.js +142 -0
- package/dist/queue/threads.js +271 -0
- package/dist/queue/worker.js +107 -1
- package/migrations/0010_create_threads.sql +27 -0
- package/migrations/meta/0010_snapshot.json +1134 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/dist/memory/journal-cadence.js +0 -120
- package/dist/memory/journal-nudger.js +0 -221
package/package.json
CHANGED
|
@@ -1,120 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { getProvider } from '../ai/providers.js';
|
|
2
|
-
import { config } from '../config.js';
|
|
3
|
-
import { initiate } from '../gateway/outgoing.js';
|
|
4
|
-
import { logger } from '../logger.js';
|
|
5
|
-
import { readLast } from '../store/messages.js';
|
|
6
|
-
import { canSendProactive } from '../wa/whitelist.js';
|
|
7
|
-
import { isInQuietHours, nextFireTs, parseCadence, } from './journal-cadence.js';
|
|
8
|
-
import { listJournals, loadNudgeState, readEntries, saveNudgeState, } from './journals.js';
|
|
9
|
-
// Default nudge target: owner's self-DM. Group/other-chat nudges would require
|
|
10
|
-
// explicit opt-in via access.json; we don't schedule those automatically in v1.
|
|
11
|
-
function defaultNudgeJid() {
|
|
12
|
-
if (!config.owner.number)
|
|
13
|
-
return null;
|
|
14
|
-
return `${config.owner.number}@s.whatsapp.net`;
|
|
15
|
-
}
|
|
16
|
-
async function spawnComposer(prompt) {
|
|
17
|
-
const { reply } = await getProvider().runTask({
|
|
18
|
-
input: prompt,
|
|
19
|
-
caller: 'journal-nudger',
|
|
20
|
-
mode: 'auto',
|
|
21
|
-
lane: 'background',
|
|
22
|
-
});
|
|
23
|
-
return reply;
|
|
24
|
-
}
|
|
25
|
-
function formatMsg(m) {
|
|
26
|
-
const who = m.direction === 'out' ? 'assistant' : m.pushName || m.senderNumber || 'user';
|
|
27
|
-
return `${who}: ${m.text}`;
|
|
28
|
-
}
|
|
29
|
-
function buildPrompt(params) {
|
|
30
|
-
const { journal, kind, nowLocal, recentEntries, recentMessages } = params;
|
|
31
|
-
const kindDescription = kind === 'checkin'
|
|
32
|
-
? "This is a scheduled recurring check-in for this journal. Ask the owner to log whatever belongs in the journal right now (e.g. for a health journal at night: how did you sleep, any symptoms, anything else)."
|
|
33
|
-
: "The owner has been silent on this journal's topic for a while. Send a gentle, natural nudge asking how things are going. Do not list every field; pick one thread.";
|
|
34
|
-
const lines = [
|
|
35
|
-
`You are composing a proactive WhatsApp message to the owner from their personal assistant bot.`,
|
|
36
|
-
``,
|
|
37
|
-
`CONTEXT:`,
|
|
38
|
-
`- Journal: ${journal.name} (slug: ${journal.slug})`,
|
|
39
|
-
`- Purpose: ${journal.purpose}`,
|
|
40
|
-
journal.fields.length
|
|
41
|
-
? `- Fields typically captured: ${journal.fields.join(', ')}`
|
|
42
|
-
: '',
|
|
43
|
-
`- Local time now: ${nowLocal}`,
|
|
44
|
-
`- Nudge type: ${kind}. ${kindDescription}`,
|
|
45
|
-
``,
|
|
46
|
-
`RECENT JOURNAL ENTRIES (for continuity):`,
|
|
47
|
-
recentEntries.length
|
|
48
|
-
? recentEntries
|
|
49
|
-
.map((e) => {
|
|
50
|
-
const d = new Date(e.ts * 1000).toISOString().slice(0, 16).replace('T', ' ');
|
|
51
|
-
return `- [${d}] ${e.note}`;
|
|
52
|
-
})
|
|
53
|
-
.join('\n')
|
|
54
|
-
: '(none yet)',
|
|
55
|
-
``,
|
|
56
|
-
`RECENT CONVERSATION (so you sound like yourself, not a stranger):`,
|
|
57
|
-
recentMessages.length
|
|
58
|
-
? recentMessages.slice(-10).map(formatMsg).join('\n')
|
|
59
|
-
: '(none)',
|
|
60
|
-
``,
|
|
61
|
-
`RULES:`,
|
|
62
|
-
`- Stay fully in character (your personality file defines tone; you are NOT customer service and NOT a reminder app).`,
|
|
63
|
-
`- Short. One message, a few sentences max. No bullet lists, no headers.`,
|
|
64
|
-
`- Do not open with validation, greetings, or "hope you're doing well" fillers. Get to the point.`,
|
|
65
|
-
`- Be specific to the journal and what you know about the owner from recent entries and conversation.`,
|
|
66
|
-
`- If previous entries show a pattern or an unresolved thread, reference it.`,
|
|
67
|
-
`- Do NOT include any [TAG: ...] markers. This message is sent raw.`,
|
|
68
|
-
`- If you truly think sending nothing is better right now (e.g. you just talked about this an hour ago), output the single word: SKIP`,
|
|
69
|
-
``,
|
|
70
|
-
`OUTPUT: either the message text, or the single word SKIP. No preamble, no explanation.`,
|
|
71
|
-
];
|
|
72
|
-
return lines.filter(Boolean).join('\n');
|
|
73
|
-
}
|
|
74
|
-
function nowLocalString(timezone, now) {
|
|
75
|
-
const fmt = new Intl.DateTimeFormat('en-GB', {
|
|
76
|
-
timeZone: timezone,
|
|
77
|
-
weekday: 'long',
|
|
78
|
-
year: 'numeric',
|
|
79
|
-
month: '2-digit',
|
|
80
|
-
day: '2-digit',
|
|
81
|
-
hour: '2-digit',
|
|
82
|
-
minute: '2-digit',
|
|
83
|
-
timeZoneName: 'short',
|
|
84
|
-
hour12: false,
|
|
85
|
-
});
|
|
86
|
-
return fmt.format(new Date(now * 1000));
|
|
87
|
-
}
|
|
88
|
-
async function composeAndSend(params) {
|
|
89
|
-
const { journal, kind, jid } = params;
|
|
90
|
-
const now = Math.floor(Date.now() / 1000);
|
|
91
|
-
const nowLocal = nowLocalString(config.owner.timezone, now);
|
|
92
|
-
const recentMessages = await readLast(jid, 30);
|
|
93
|
-
const recentEntries = readEntries(journal.slug, 10).map((e) => ({
|
|
94
|
-
ts: e.ts,
|
|
95
|
-
note: e.note,
|
|
96
|
-
}));
|
|
97
|
-
const prompt = buildPrompt({
|
|
98
|
-
journal,
|
|
99
|
-
kind,
|
|
100
|
-
nowLocal,
|
|
101
|
-
recentEntries,
|
|
102
|
-
recentMessages,
|
|
103
|
-
});
|
|
104
|
-
let output;
|
|
105
|
-
try {
|
|
106
|
-
output = await spawnComposer(prompt);
|
|
107
|
-
}
|
|
108
|
-
catch (err) {
|
|
109
|
-
logger.error({ err, slug: journal.slug, kind }, 'nudge composer failed');
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
const text = output.trim();
|
|
113
|
-
if (!text || text.toUpperCase() === 'SKIP') {
|
|
114
|
-
logger.info({ slug: journal.slug, kind }, 'nudge composer chose to skip');
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
const sent = await initiate({ jid, text });
|
|
118
|
-
return sent;
|
|
119
|
-
}
|
|
120
|
-
function lastOwnerInboundTs(messages) {
|
|
121
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
122
|
-
const m = messages[i];
|
|
123
|
-
if (m.direction === 'in' && m.senderNumber === config.owner.number) {
|
|
124
|
-
return m.timestamp;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return 0;
|
|
128
|
-
}
|
|
129
|
-
function needCheckin(journal, state, now) {
|
|
130
|
-
const cadence = parseCadence(journal.cadence.checkin);
|
|
131
|
-
if (!cadence)
|
|
132
|
-
return false;
|
|
133
|
-
const next = nextFireTs({
|
|
134
|
-
cadence,
|
|
135
|
-
lastFiredTs: state.lastCheckinTs || null,
|
|
136
|
-
now,
|
|
137
|
-
timezone: config.owner.timezone,
|
|
138
|
-
});
|
|
139
|
-
return now >= next;
|
|
140
|
-
}
|
|
141
|
-
function needSilentNudge(params) {
|
|
142
|
-
const { journal, state, now, lastOwnerActivityTs } = params;
|
|
143
|
-
const cadence = parseCadence(journal.cadence.nudge_if_silent);
|
|
144
|
-
if (!cadence || cadence.kind !== 'interval')
|
|
145
|
-
return false;
|
|
146
|
-
const silenceThresholdSec = cadence.seconds;
|
|
147
|
-
if (now - lastOwnerActivityTs < silenceThresholdSec)
|
|
148
|
-
return false;
|
|
149
|
-
// Debounce: don't resend the same silent nudge more often than the threshold.
|
|
150
|
-
if (now - state.lastSilentNudgeTs < silenceThresholdSec)
|
|
151
|
-
return false;
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
export async function runNudgeTick() {
|
|
155
|
-
const jid = defaultNudgeJid();
|
|
156
|
-
if (!jid) {
|
|
157
|
-
logger.warn('nudge tick: no owner.number, skipping');
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (!canSendProactive(jid)) {
|
|
161
|
-
logger.debug({ jid }, 'nudge tick: proactive sending not allowed for target jid');
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
const now = Math.floor(Date.now() / 1000);
|
|
165
|
-
if (isInQuietHours({
|
|
166
|
-
now,
|
|
167
|
-
window: '22:00-08:00',
|
|
168
|
-
timezone: config.owner.timezone,
|
|
169
|
-
})) {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
const journals = listJournals().filter((j) => j.status === 'active');
|
|
173
|
-
for (const journal of journals) {
|
|
174
|
-
const state = loadNudgeState(journal.slug);
|
|
175
|
-
if (state.snoozedUntilTs && now < state.snoozedUntilTs)
|
|
176
|
-
continue;
|
|
177
|
-
// Per-journal quiet hours override
|
|
178
|
-
if (isInQuietHours({
|
|
179
|
-
now,
|
|
180
|
-
window: journal.quiet_hours,
|
|
181
|
-
timezone: config.owner.timezone,
|
|
182
|
-
})) {
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
const recent = await readLast(jid, 50);
|
|
186
|
-
const lastOwnerTs = lastOwnerInboundTs(recent);
|
|
187
|
-
if (needCheckin(journal, state, now)) {
|
|
188
|
-
const sent = await composeAndSend({
|
|
189
|
-
journal,
|
|
190
|
-
kind: 'checkin',
|
|
191
|
-
jid,
|
|
192
|
-
});
|
|
193
|
-
if (sent) {
|
|
194
|
-
const fresh = loadNudgeState(journal.slug);
|
|
195
|
-
fresh.lastCheckinTs = now;
|
|
196
|
-
saveNudgeState(journal.slug, fresh);
|
|
197
|
-
logger.info({ slug: journal.slug, kind: 'checkin' }, 'nudge sent');
|
|
198
|
-
}
|
|
199
|
-
// Don't also send a silent nudge in the same tick for this journal.
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
if (needSilentNudge({
|
|
203
|
-
journal,
|
|
204
|
-
state,
|
|
205
|
-
now,
|
|
206
|
-
lastOwnerActivityTs: lastOwnerTs,
|
|
207
|
-
})) {
|
|
208
|
-
const sent = await composeAndSend({
|
|
209
|
-
journal,
|
|
210
|
-
kind: 'silent',
|
|
211
|
-
jid,
|
|
212
|
-
});
|
|
213
|
-
if (sent) {
|
|
214
|
-
const fresh = loadNudgeState(journal.slug);
|
|
215
|
-
fresh.lastSilentNudgeTs = now;
|
|
216
|
-
saveNudgeState(journal.slug, fresh);
|
|
217
|
-
logger.info({ slug: journal.slug, kind: 'silent' }, 'nudge sent');
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|