@c4t4/heyamigo 0.1.18 → 0.5.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.
@@ -0,0 +1,282 @@
1
+ import { spawn } from 'child_process';
2
+ import { config } from '../config.js';
3
+ import { initiate } from '../gateway/outgoing.js';
4
+ import { logger } from '../logger.js';
5
+ import { logPrompt } from '../promptlog.js';
6
+ import { readLast } from '../store/messages.js';
7
+ import { canSendProactive } from '../wa/whitelist.js';
8
+ import { isInQuietHours, nextFireTs, parseCadence, } from './journal-cadence.js';
9
+ import { listJournals, loadNudgeState, readEntries, saveNudgeState, } from './journals.js';
10
+ // Default nudge target: owner's self-DM. Group/other-chat nudges would require
11
+ // explicit opt-in via access.json; we don't schedule those automatically in v1.
12
+ function defaultNudgeJid() {
13
+ if (!config.owner.number)
14
+ return null;
15
+ return `${config.owner.number}@s.whatsapp.net`;
16
+ }
17
+ async function spawnComposer(prompt) {
18
+ const args = [
19
+ '-p',
20
+ '--output-format',
21
+ 'json',
22
+ '--model',
23
+ config.claude.model,
24
+ '--permission-mode',
25
+ 'acceptEdits',
26
+ ];
27
+ const startedAt = Date.now();
28
+ return new Promise((resolvePromise, rejectPromise) => {
29
+ const child = spawn('claude', args, {
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ cwd: process.cwd(),
32
+ });
33
+ let stdout = '';
34
+ let stderr = '';
35
+ child.stdout.on('data', (c) => {
36
+ stdout += c.toString('utf-8');
37
+ });
38
+ child.stderr.on('data', (c) => {
39
+ stderr += c.toString('utf-8');
40
+ });
41
+ const logFail = (error) => void logPrompt({
42
+ ts: Math.floor(startedAt / 1000),
43
+ caller: 'journal-nudger',
44
+ args,
45
+ input: prompt,
46
+ error,
47
+ durationMs: Date.now() - startedAt,
48
+ });
49
+ child.on('error', (err) => {
50
+ logFail(`spawn failed: ${err.message}`);
51
+ rejectPromise(err);
52
+ });
53
+ child.on('close', (code) => {
54
+ if (code !== 0) {
55
+ logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
56
+ return rejectPromise(new Error(`nudger exit ${code}`));
57
+ }
58
+ try {
59
+ const parsed = JSON.parse(stdout);
60
+ if (parsed.is_error ||
61
+ parsed.subtype !== 'success' ||
62
+ !parsed.result) {
63
+ logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
64
+ return rejectPromise(new Error('nudger bad output'));
65
+ }
66
+ const output = parsed.result.trim();
67
+ void logPrompt({
68
+ ts: Math.floor(startedAt / 1000),
69
+ caller: 'journal-nudger',
70
+ args,
71
+ input: prompt,
72
+ output,
73
+ durationMs: Date.now() - startedAt,
74
+ });
75
+ resolvePromise(output);
76
+ }
77
+ catch (err) {
78
+ logFail(`parse failed: ${err.message}`);
79
+ rejectPromise(err);
80
+ }
81
+ });
82
+ child.stdin.write(prompt);
83
+ child.stdin.end();
84
+ });
85
+ }
86
+ function formatMsg(m) {
87
+ const who = m.direction === 'out' ? 'assistant' : m.pushName || m.senderNumber || 'user';
88
+ return `${who}: ${m.text}`;
89
+ }
90
+ function buildPrompt(params) {
91
+ const { journal, kind, nowLocal, recentEntries, recentMessages } = params;
92
+ const kindDescription = kind === 'checkin'
93
+ ? "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)."
94
+ : "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.";
95
+ const lines = [
96
+ `You are composing a proactive WhatsApp message to the owner from their personal assistant bot.`,
97
+ ``,
98
+ `CONTEXT:`,
99
+ `- Journal: ${journal.name} (slug: ${journal.slug})`,
100
+ `- Purpose: ${journal.purpose}`,
101
+ journal.fields.length
102
+ ? `- Fields typically captured: ${journal.fields.join(', ')}`
103
+ : '',
104
+ `- Local time now: ${nowLocal}`,
105
+ `- Nudge type: ${kind}. ${kindDescription}`,
106
+ ``,
107
+ `RECENT JOURNAL ENTRIES (for continuity):`,
108
+ recentEntries.length
109
+ ? recentEntries
110
+ .map((e) => {
111
+ const d = new Date(e.ts * 1000).toISOString().slice(0, 16).replace('T', ' ');
112
+ return `- [${d}] ${e.note}`;
113
+ })
114
+ .join('\n')
115
+ : '(none yet)',
116
+ ``,
117
+ `RECENT CONVERSATION (so you sound like yourself, not a stranger):`,
118
+ recentMessages.length
119
+ ? recentMessages.slice(-10).map(formatMsg).join('\n')
120
+ : '(none)',
121
+ ``,
122
+ `RULES:`,
123
+ `- Stay fully in character (your personality file defines tone; you are NOT customer service and NOT a reminder app).`,
124
+ `- Short. One message, a few sentences max. No bullet lists, no headers.`,
125
+ `- Do not open with validation, greetings, or "hope you're doing well" fillers. Get to the point.`,
126
+ `- Be specific to the journal and what you know about the owner from recent entries and conversation.`,
127
+ `- If previous entries show a pattern or an unresolved thread, reference it.`,
128
+ `- Do NOT include any [TAG: ...] markers. This message is sent raw.`,
129
+ `- 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`,
130
+ ``,
131
+ `OUTPUT: either the message text, or the single word SKIP. No preamble, no explanation.`,
132
+ ];
133
+ return lines.filter(Boolean).join('\n');
134
+ }
135
+ function nowLocalString(timezone, now) {
136
+ const fmt = new Intl.DateTimeFormat('en-GB', {
137
+ timeZone: timezone,
138
+ weekday: 'long',
139
+ year: 'numeric',
140
+ month: '2-digit',
141
+ day: '2-digit',
142
+ hour: '2-digit',
143
+ minute: '2-digit',
144
+ timeZoneName: 'short',
145
+ hour12: false,
146
+ });
147
+ return fmt.format(new Date(now * 1000));
148
+ }
149
+ async function composeAndSend(params) {
150
+ const { journal, kind, jid } = params;
151
+ const now = Math.floor(Date.now() / 1000);
152
+ const nowLocal = nowLocalString(config.owner.timezone, now);
153
+ const recentMessages = await readLast(jid, 30);
154
+ const recentEntries = readEntries(journal.slug, 10).map((e) => ({
155
+ ts: e.ts,
156
+ note: e.note,
157
+ }));
158
+ const prompt = buildPrompt({
159
+ journal,
160
+ kind,
161
+ nowLocal,
162
+ recentEntries,
163
+ recentMessages,
164
+ });
165
+ let output;
166
+ try {
167
+ output = await spawnComposer(prompt);
168
+ }
169
+ catch (err) {
170
+ logger.error({ err, slug: journal.slug, kind }, 'nudge composer failed');
171
+ return false;
172
+ }
173
+ const text = output.trim();
174
+ if (!text || text.toUpperCase() === 'SKIP') {
175
+ logger.info({ slug: journal.slug, kind }, 'nudge composer chose to skip');
176
+ return false;
177
+ }
178
+ const sent = await initiate({ jid, text });
179
+ return sent;
180
+ }
181
+ function lastOwnerInboundTs(messages) {
182
+ for (let i = messages.length - 1; i >= 0; i--) {
183
+ const m = messages[i];
184
+ if (m.direction === 'in' && m.senderNumber === config.owner.number) {
185
+ return m.timestamp;
186
+ }
187
+ }
188
+ return 0;
189
+ }
190
+ function needCheckin(journal, state, now) {
191
+ const cadence = parseCadence(journal.cadence.checkin);
192
+ if (!cadence)
193
+ return false;
194
+ const next = nextFireTs({
195
+ cadence,
196
+ lastFiredTs: state.lastCheckinTs || null,
197
+ now,
198
+ timezone: config.owner.timezone,
199
+ });
200
+ return now >= next;
201
+ }
202
+ function needSilentNudge(params) {
203
+ const { journal, state, now, lastOwnerActivityTs } = params;
204
+ const cadence = parseCadence(journal.cadence.nudge_if_silent);
205
+ if (!cadence || cadence.kind !== 'interval')
206
+ return false;
207
+ const silenceThresholdSec = cadence.seconds;
208
+ if (now - lastOwnerActivityTs < silenceThresholdSec)
209
+ return false;
210
+ // Debounce: don't resend the same silent nudge more often than the threshold.
211
+ if (now - state.lastSilentNudgeTs < silenceThresholdSec)
212
+ return false;
213
+ return true;
214
+ }
215
+ export async function runNudgeTick() {
216
+ const jid = defaultNudgeJid();
217
+ if (!jid) {
218
+ logger.warn('nudge tick: no owner.number, skipping');
219
+ return;
220
+ }
221
+ if (!canSendProactive(jid)) {
222
+ logger.debug({ jid }, 'nudge tick: proactive sending not allowed for target jid');
223
+ return;
224
+ }
225
+ const now = Math.floor(Date.now() / 1000);
226
+ if (isInQuietHours({
227
+ now,
228
+ window: '22:00-08:00',
229
+ timezone: config.owner.timezone,
230
+ })) {
231
+ return;
232
+ }
233
+ const journals = listJournals().filter((j) => j.status === 'active');
234
+ for (const journal of journals) {
235
+ const state = loadNudgeState(journal.slug);
236
+ if (state.snoozedUntilTs && now < state.snoozedUntilTs)
237
+ continue;
238
+ // Per-journal quiet hours override
239
+ if (isInQuietHours({
240
+ now,
241
+ window: journal.quiet_hours,
242
+ timezone: config.owner.timezone,
243
+ })) {
244
+ continue;
245
+ }
246
+ const recent = await readLast(jid, 50);
247
+ const lastOwnerTs = lastOwnerInboundTs(recent);
248
+ if (needCheckin(journal, state, now)) {
249
+ const sent = await composeAndSend({
250
+ journal,
251
+ kind: 'checkin',
252
+ jid,
253
+ });
254
+ if (sent) {
255
+ const fresh = loadNudgeState(journal.slug);
256
+ fresh.lastCheckinTs = now;
257
+ saveNudgeState(journal.slug, fresh);
258
+ logger.info({ slug: journal.slug, kind: 'checkin' }, 'nudge sent');
259
+ }
260
+ // Don't also send a silent nudge in the same tick for this journal.
261
+ continue;
262
+ }
263
+ if (needSilentNudge({
264
+ journal,
265
+ state,
266
+ now,
267
+ lastOwnerActivityTs: lastOwnerTs,
268
+ })) {
269
+ const sent = await composeAndSend({
270
+ journal,
271
+ kind: 'silent',
272
+ jid,
273
+ });
274
+ if (sent) {
275
+ const fresh = loadNudgeState(journal.slug);
276
+ fresh.lastSilentNudgeTs = now;
277
+ saveNudgeState(journal.slug, fresh);
278
+ logger.info({ slug: journal.slug, kind: 'silent' }, 'nudge sent');
279
+ }
280
+ }
281
+ }
282
+ }
@@ -0,0 +1,225 @@
1
+ import { spawn } from 'child_process';
2
+ import { config } from '../config.js';
3
+ import { logger } from '../logger.js';
4
+ import { logPrompt } from '../promptlog.js';
5
+ import { readLast } from '../store/messages.js';
6
+ import { appendEntry, getLastScannedTs, getJournal, readEntries, setLastScannedTs, } from './journals.js';
7
+ // How many recent messages to include in the scan window on each sweep.
8
+ // Observer runs every memory.sweepIntervalMs (default 3h), so this window
9
+ // must cover at least that much chat activity to avoid gaps.
10
+ const SCAN_WINDOW = 200;
11
+ // How many recent entries to show Claude for dedup context.
12
+ const DEDUP_WINDOW = 20;
13
+ async function spawnObserver(prompt) {
14
+ const args = [
15
+ '-p',
16
+ '--output-format',
17
+ 'json',
18
+ '--model',
19
+ config.claude.model,
20
+ '--permission-mode',
21
+ 'acceptEdits',
22
+ ];
23
+ const startedAt = Date.now();
24
+ return new Promise((resolvePromise, rejectPromise) => {
25
+ const child = spawn('claude', args, {
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ cwd: process.cwd(),
28
+ });
29
+ let stdout = '';
30
+ let stderr = '';
31
+ child.stdout.on('data', (chunk) => {
32
+ stdout += chunk.toString('utf-8');
33
+ });
34
+ child.stderr.on('data', (chunk) => {
35
+ stderr += chunk.toString('utf-8');
36
+ });
37
+ const logFail = (error) => void logPrompt({
38
+ ts: Math.floor(startedAt / 1000),
39
+ caller: 'journal-observer',
40
+ args,
41
+ input: prompt,
42
+ error,
43
+ durationMs: Date.now() - startedAt,
44
+ });
45
+ child.on('error', (err) => {
46
+ logFail(`spawn failed: ${err.message}`);
47
+ rejectPromise(new Error(`journal observer spawn failed: ${err.message}`));
48
+ });
49
+ child.on('close', (code) => {
50
+ if (code !== 0) {
51
+ logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
52
+ return rejectPromise(new Error(`journal observer exit ${code}: ${stderr.slice(0, 300)}`));
53
+ }
54
+ try {
55
+ const parsed = JSON.parse(stdout);
56
+ if (parsed.is_error ||
57
+ parsed.subtype !== 'success' ||
58
+ !parsed.result) {
59
+ logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
60
+ return rejectPromise(new Error(`journal observer bad output: ${parsed.result ?? stderr.slice(0, 200)}`));
61
+ }
62
+ const output = parsed.result.trim();
63
+ void logPrompt({
64
+ ts: Math.floor(startedAt / 1000),
65
+ caller: 'journal-observer',
66
+ args,
67
+ input: prompt,
68
+ output,
69
+ durationMs: Date.now() - startedAt,
70
+ });
71
+ resolvePromise(output);
72
+ }
73
+ catch (err) {
74
+ logFail(`parse failed: ${err.message}`);
75
+ rejectPromise(new Error(`journal observer parse failed: ${err.message}`));
76
+ }
77
+ });
78
+ child.stdin.write(prompt);
79
+ child.stdin.end();
80
+ });
81
+ }
82
+ function formatMsg(m) {
83
+ const date = new Date(m.timestamp * 1000)
84
+ .toISOString()
85
+ .slice(0, 16)
86
+ .replace('T', ' ');
87
+ const who = m.direction === 'out' ? 'assistant' : m.pushName || m.senderNumber || 'user';
88
+ return `[${m.timestamp}] ${who} (${date}): ${m.text}`;
89
+ }
90
+ function buildPrompt(params) {
91
+ const { journal, recentEntries, messages } = params;
92
+ const lines = [
93
+ `You are a silent observer for a long-running journal called "${journal.name}" (slug: ${journal.slug}).`,
94
+ ``,
95
+ `PURPOSE: ${journal.purpose}`,
96
+ journal.fields.length
97
+ ? `FIELDS TO CAPTURE: ${journal.fields.join(', ')}`
98
+ : '',
99
+ ``,
100
+ `Your job: scan the new messages below and decide if any of them contain content that belongs in this journal. Extract zero or more new entries.`,
101
+ ``,
102
+ `RECENT ENTRIES (to avoid duplicates — do not re-log anything already captured here):`,
103
+ recentEntries.length
104
+ ? recentEntries
105
+ .map((e) => {
106
+ const d = new Date(e.ts * 1000).toISOString().slice(0, 16).replace('T', ' ');
107
+ return `- [${d}] ${e.note}`;
108
+ })
109
+ .join('\n')
110
+ : '(none yet)',
111
+ ``,
112
+ `NEW MESSAGES TO SCAN:`,
113
+ messages.map(formatMsg).join('\n'),
114
+ ``,
115
+ `RULES:`,
116
+ `- Only extract content that genuinely belongs in the "${journal.name}" journal based on its purpose.`,
117
+ `- Ignore small talk, logistics, unrelated topics.`,
118
+ `- Do not invent or infer things the messages don't say.`,
119
+ `- If the owner mentioned the same thing twice in this window, log it once.`,
120
+ `- Skip anything already in RECENT ENTRIES above.`,
121
+ ``,
122
+ `OUTPUT FORMAT:`,
123
+ `- If there are no new entries to log, output exactly the single word: NONE`,
124
+ `- Otherwise, output one JSON object per line (JSONL), each with this exact shape:`,
125
+ ` {"note": "<one-line summary of the entry>"}`,
126
+ `- No preamble, no trailing text, no markdown, no code fences.`,
127
+ ];
128
+ return lines.filter(Boolean).join('\n');
129
+ }
130
+ function parseOutput(raw) {
131
+ const trimmed = raw.trim();
132
+ if (!trimmed || trimmed.toUpperCase() === 'NONE')
133
+ return [];
134
+ const out = [];
135
+ for (const line of trimmed.split(/\r?\n/)) {
136
+ const l = line.trim();
137
+ if (!l)
138
+ continue;
139
+ try {
140
+ const parsed = JSON.parse(l);
141
+ if (typeof parsed.note === 'string' && parsed.note.trim()) {
142
+ out.push({ note: parsed.note.trim() });
143
+ }
144
+ }
145
+ catch {
146
+ // skip malformed line
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+ // Default scan scope: owner's self-DM. Journals can override later by adding a
152
+ // scan_jids frontmatter field (not yet implemented — this keeps the default
153
+ // behavior safe).
154
+ function defaultScanJids() {
155
+ if (!config.owner.number)
156
+ return [];
157
+ return [`${config.owner.number}@s.whatsapp.net`];
158
+ }
159
+ export async function runJournalObserverForJid(params) {
160
+ const { slug, jid } = params;
161
+ const journal = getJournal(slug);
162
+ if (!journal) {
163
+ logger.warn({ slug }, 'journal observer: slug not found, skipping');
164
+ return { appended: 0, scanned: 0 };
165
+ }
166
+ if (journal.status !== 'active') {
167
+ return { appended: 0, scanned: 0 };
168
+ }
169
+ const since = getLastScannedTs(slug, jid);
170
+ const recent = await readLast(jid, SCAN_WINDOW);
171
+ const newMessages = recent.filter((m) => m.timestamp > since);
172
+ if (newMessages.length === 0) {
173
+ return { appended: 0, scanned: 0 };
174
+ }
175
+ const recentEntries = readEntries(slug, DEDUP_WINDOW).map((e) => ({
176
+ ts: e.ts,
177
+ note: e.note,
178
+ }));
179
+ const prompt = buildPrompt({
180
+ journal,
181
+ recentEntries,
182
+ messages: newMessages,
183
+ });
184
+ let output;
185
+ try {
186
+ output = await spawnObserver(prompt);
187
+ }
188
+ catch (err) {
189
+ logger.error({ err, slug, jid }, 'journal observer pass failed');
190
+ return { appended: 0, scanned: newMessages.length };
191
+ }
192
+ const entries = parseOutput(output);
193
+ for (const e of entries) {
194
+ appendEntry(slug, {
195
+ source: 'observer',
196
+ jid,
197
+ note: e.note,
198
+ });
199
+ }
200
+ const maxTs = newMessages[newMessages.length - 1].timestamp;
201
+ setLastScannedTs(slug, jid, maxTs);
202
+ logger.info({ slug, jid, scanned: newMessages.length, appended: entries.length }, 'journal observer pass complete');
203
+ return { appended: entries.length, scanned: newMessages.length };
204
+ }
205
+ export async function runJournalObserverSweep() {
206
+ const { listJournals } = await import('./journals.js');
207
+ const journals = listJournals().filter((j) => j.status === 'active');
208
+ if (journals.length === 0)
209
+ return;
210
+ const jids = defaultScanJids();
211
+ if (jids.length === 0) {
212
+ logger.warn('journal observer: no owner.number configured, skipping sweep');
213
+ return;
214
+ }
215
+ for (const journal of journals) {
216
+ for (const jid of jids) {
217
+ try {
218
+ await runJournalObserverForJid({ slug: journal.slug, jid });
219
+ }
220
+ catch (err) {
221
+ logger.error({ err, slug: journal.slug, jid }, 'journal observer sweep error');
222
+ }
223
+ }
224
+ }
225
+ }