@c4t4/heyamigo 0.1.18 → 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.
@@ -0,0 +1,314 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
+ import { logger } from '../logger.js';
4
+ import { parseFrontmatter, serializeFrontmatter, } from './frontmatter.js';
5
+ import { memoryRoot } from './paths.js';
6
+ // ---------- paths ----------
7
+ function journalsRoot() {
8
+ return resolve(memoryRoot(), 'journals');
9
+ }
10
+ function journalDir(slug) {
11
+ return resolve(journalsRoot(), slug);
12
+ }
13
+ function journalIndexPath(slug) {
14
+ return resolve(journalDir(slug), 'index.md');
15
+ }
16
+ function journalEntriesPath(slug) {
17
+ return resolve(journalDir(slug), 'entries.jsonl');
18
+ }
19
+ function journalsIndexPath() {
20
+ return resolve(journalsRoot(), 'index.md');
21
+ }
22
+ function journalObserverStatePath(slug) {
23
+ return resolve(journalDir(slug), 'observer-state.json');
24
+ }
25
+ function journalNudgeStatePath(slug) {
26
+ return resolve(journalDir(slug), 'nudge-state.json');
27
+ }
28
+ // ---------- low-level fs ----------
29
+ function ensureDirFor(path) {
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ }
32
+ function readIfExists(path) {
33
+ if (!existsSync(path))
34
+ return null;
35
+ return readFileSync(path, 'utf-8');
36
+ }
37
+ // ---------- scaffold ----------
38
+ export function ensureJournalsScaffold() {
39
+ mkdirSync(journalsRoot(), { recursive: true });
40
+ if (!existsSync(journalsIndexPath())) {
41
+ writeFileSync(journalsIndexPath(), '# journals\n\n(empty)\n', 'utf-8');
42
+ }
43
+ }
44
+ // ---------- slug rules ----------
45
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
46
+ export function isValidSlug(slug) {
47
+ return SLUG_RE.test(slug);
48
+ }
49
+ // ---------- parse / serialize ----------
50
+ function parseJournal(slug, raw) {
51
+ const { data, body } = parseFrontmatter(raw);
52
+ const name = typeof data.name === 'string' ? data.name : slug;
53
+ const purpose = typeof data.purpose === 'string' ? data.purpose : '';
54
+ const fields = Array.isArray(data.fields)
55
+ ? data.fields.map(String)
56
+ : [];
57
+ const status = data.status === 'paused' || data.status === 'archived'
58
+ ? data.status
59
+ : 'active';
60
+ const cadence = {
61
+ checkin: pickString(data.checkin),
62
+ followup_after: pickString(data.followup_after),
63
+ nudge_if_silent: pickString(data.nudge_if_silent),
64
+ };
65
+ const created_at = typeof data.created_at === 'string' ? data.created_at : '';
66
+ const updated_at = typeof data.updated_at === 'string' ? data.updated_at : created_at;
67
+ return {
68
+ slug,
69
+ name,
70
+ purpose,
71
+ fields,
72
+ cadence,
73
+ status,
74
+ quiet_hours: pickString(data.quiet_hours),
75
+ created_at,
76
+ updated_at,
77
+ body,
78
+ };
79
+ }
80
+ function pickString(v) {
81
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
82
+ }
83
+ function journalToFrontmatter(j) {
84
+ const fm = {
85
+ slug: j.slug,
86
+ name: j.name,
87
+ purpose: j.purpose,
88
+ fields: j.fields,
89
+ status: j.status,
90
+ created_at: j.created_at,
91
+ updated_at: j.updated_at,
92
+ };
93
+ if (j.cadence.checkin)
94
+ fm.checkin = j.cadence.checkin;
95
+ if (j.cadence.followup_after)
96
+ fm.followup_after = j.cadence.followup_after;
97
+ if (j.cadence.nudge_if_silent)
98
+ fm.nudge_if_silent = j.cadence.nudge_if_silent;
99
+ if (j.quiet_hours)
100
+ fm.quiet_hours = j.quiet_hours;
101
+ return fm;
102
+ }
103
+ // ---------- CRUD ----------
104
+ export function listJournals() {
105
+ const root = journalsRoot();
106
+ if (!existsSync(root))
107
+ return [];
108
+ const slugs = readdirSync(root, { withFileTypes: true })
109
+ .filter((d) => d.isDirectory())
110
+ .map((d) => d.name)
111
+ .sort();
112
+ const out = [];
113
+ for (const slug of slugs) {
114
+ const raw = readIfExists(journalIndexPath(slug));
115
+ if (!raw)
116
+ continue;
117
+ const j = parseJournal(slug, raw);
118
+ if (j)
119
+ out.push(j);
120
+ }
121
+ return out;
122
+ }
123
+ export function getJournal(slug) {
124
+ if (!isValidSlug(slug))
125
+ return null;
126
+ const raw = readIfExists(journalIndexPath(slug));
127
+ if (!raw)
128
+ return null;
129
+ return parseJournal(slug, raw);
130
+ }
131
+ export function journalExists(slug) {
132
+ return isValidSlug(slug) && existsSync(journalIndexPath(slug));
133
+ }
134
+ export function createJournal(input) {
135
+ if (!isValidSlug(input.slug)) {
136
+ throw new Error(`Invalid journal slug "${input.slug}". Use lowercase letters, digits, and hyphens (max 48 chars, must start with letter/digit).`);
137
+ }
138
+ if (journalExists(input.slug)) {
139
+ throw new Error(`Journal "${input.slug}" already exists.`);
140
+ }
141
+ const now = new Date().toISOString().slice(0, 10);
142
+ // Default cadence: nudge after 3 days of silence on this topic. No daily
143
+ // check-in by default — that would be too pushy for most journals. Owner
144
+ // can tune by editing the journal's index.md frontmatter directly.
145
+ const cadence = input.cadence ?? {
146
+ nudge_if_silent: '3d',
147
+ };
148
+ const journal = {
149
+ slug: input.slug,
150
+ name: input.name,
151
+ purpose: input.purpose,
152
+ fields: input.fields ?? [],
153
+ cadence,
154
+ status: 'active',
155
+ quiet_hours: input.quiet_hours,
156
+ created_at: now,
157
+ updated_at: now,
158
+ body: input.body ??
159
+ `# ${input.name}\n\n${input.purpose}\n\n## How this journal is used\n\nEntries are captured by the assistant when topics relevant to this journal come up. See entries.jsonl for the log.\n`,
160
+ };
161
+ writeJournal(journal);
162
+ refreshJournalsIndex();
163
+ logger.info({ slug: journal.slug }, 'journal created');
164
+ return journal;
165
+ }
166
+ export function writeJournal(j) {
167
+ const content = serializeFrontmatter(journalToFrontmatter(j), j.body);
168
+ const path = journalIndexPath(j.slug);
169
+ ensureDirFor(path);
170
+ writeFileSync(path, content, 'utf-8');
171
+ }
172
+ export function updateJournalStatus(slug, status) {
173
+ const j = getJournal(slug);
174
+ if (!j)
175
+ return null;
176
+ j.status = status;
177
+ j.updated_at = new Date().toISOString().slice(0, 10);
178
+ writeJournal(j);
179
+ refreshJournalsIndex();
180
+ return j;
181
+ }
182
+ // ---------- entries ----------
183
+ export function appendEntry(slug, entry) {
184
+ if (!journalExists(slug)) {
185
+ logger.warn({ slug }, 'journal append ignored: unknown slug');
186
+ return false;
187
+ }
188
+ const full = {
189
+ ts: entry.ts ?? Math.floor(Date.now() / 1000),
190
+ source: entry.source,
191
+ jid: entry.jid,
192
+ senderNumber: entry.senderNumber,
193
+ note: entry.note,
194
+ };
195
+ const path = journalEntriesPath(slug);
196
+ ensureDirFor(path);
197
+ appendFileSync(path, JSON.stringify(full) + '\n', 'utf-8');
198
+ logger.info({ slug, source: full.source, jid: full.jid }, 'journal entry appended');
199
+ return true;
200
+ }
201
+ export function readEntries(slug, limit = 100) {
202
+ const raw = readIfExists(journalEntriesPath(slug));
203
+ if (!raw)
204
+ return [];
205
+ const lines = raw.trim().split(/\r?\n/).filter(Boolean);
206
+ const tail = limit > 0 ? lines.slice(-limit) : lines;
207
+ const out = [];
208
+ for (const line of tail) {
209
+ try {
210
+ out.push(JSON.parse(line));
211
+ }
212
+ catch {
213
+ // skip malformed line
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+ // ---------- index ----------
219
+ export function refreshJournalsIndex() {
220
+ const journals = listJournals();
221
+ const lines = ['# journals', ''];
222
+ if (journals.length === 0) {
223
+ lines.push('(empty)');
224
+ }
225
+ else {
226
+ for (const j of journals) {
227
+ lines.push(`- ${j.slug}/ [${j.status}] — ${j.purpose || j.name}`);
228
+ }
229
+ }
230
+ lines.push('');
231
+ const path = journalsIndexPath();
232
+ ensureDirFor(path);
233
+ writeFileSync(path, lines.join('\n'), 'utf-8');
234
+ }
235
+ // ---------- preamble helper ----------
236
+ // Short one-liner per active journal for the [Journals: active] preamble block.
237
+ // Only returns active journals (not paused/archived).
238
+ export function buildJournalsPreambleBlock() {
239
+ const journals = listJournals().filter((j) => j.status === 'active');
240
+ if (journals.length === 0)
241
+ return null;
242
+ const lines = [];
243
+ for (const j of journals) {
244
+ const cadence = summarizeCadence(j.cadence);
245
+ const cadenceSuffix = cadence ? ` (${cadence})` : '';
246
+ lines.push(`- ${j.slug}: ${j.purpose || j.name}${cadenceSuffix}`);
247
+ }
248
+ return lines.join('\n');
249
+ }
250
+ function summarizeCadence(c) {
251
+ const bits = [];
252
+ if (c.checkin)
253
+ bits.push(`check-in ${c.checkin}`);
254
+ if (c.followup_after)
255
+ bits.push(`follow-up ${c.followup_after}`);
256
+ if (c.nudge_if_silent)
257
+ bits.push(`nudge if silent ${c.nudge_if_silent}`);
258
+ return bits.join('; ');
259
+ }
260
+ export function loadObserverState(slug) {
261
+ const raw = readIfExists(journalObserverStatePath(slug));
262
+ if (!raw)
263
+ return { jids: {} };
264
+ try {
265
+ const parsed = JSON.parse(raw);
266
+ return { jids: parsed.jids ?? {} };
267
+ }
268
+ catch {
269
+ return { jids: {} };
270
+ }
271
+ }
272
+ export function saveObserverState(slug, state) {
273
+ const path = journalObserverStatePath(slug);
274
+ ensureDirFor(path);
275
+ writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
276
+ }
277
+ export function getLastScannedTs(slug, jid) {
278
+ const state = loadObserverState(slug);
279
+ return state.jids[jid]?.lastScannedTs ?? 0;
280
+ }
281
+ export function setLastScannedTs(slug, jid, ts) {
282
+ const state = loadObserverState(slug);
283
+ state.jids[jid] = { lastScannedTs: ts };
284
+ saveObserverState(slug, state);
285
+ }
286
+ export function loadNudgeState(slug) {
287
+ const raw = readIfExists(journalNudgeStatePath(slug));
288
+ if (!raw)
289
+ return { lastCheckinTs: 0, lastSilentNudgeTs: 0, snoozedUntilTs: 0 };
290
+ try {
291
+ const parsed = JSON.parse(raw);
292
+ return {
293
+ lastCheckinTs: parsed.lastCheckinTs ?? 0,
294
+ lastSilentNudgeTs: parsed.lastSilentNudgeTs ?? 0,
295
+ snoozedUntilTs: parsed.snoozedUntilTs ?? 0,
296
+ };
297
+ }
298
+ catch {
299
+ return { lastCheckinTs: 0, lastSilentNudgeTs: 0, snoozedUntilTs: 0 };
300
+ }
301
+ }
302
+ export function saveNudgeState(slug, state) {
303
+ const path = journalNudgeStatePath(slug);
304
+ ensureDirFor(path);
305
+ writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
306
+ }
307
+ export function snoozeJournal(slug, untilTs) {
308
+ if (!journalExists(slug))
309
+ return false;
310
+ const state = loadNudgeState(slug);
311
+ state.snoozedUntilTs = untilTs;
312
+ saveNudgeState(slug, state);
313
+ return true;
314
+ }
@@ -1,11 +1,13 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import { config } from '../config.js';
4
+ import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
4
5
  import { masterIndexPath, treeIndexPath } from './paths.js';
5
6
  import { routeIndexes } from './router.js';
6
7
  import { ensureScaffold } from './store.js';
7
8
  import { getRoleForContext } from '../wa/whitelist.js';
8
9
  const DIGEST_REMINDER = `When something worth remembering happens (new preference, key fact, life event, changed plan), append [DIGEST: <one-line reason>] to the END of your reply. It will be stripped before sending. Flag sparingly.`;
10
+ const JOURNAL_REMINDER = `When a message contains info for one of the journals above, append [JOURNAL:<slug> — <one-line note>] to the END of your reply. Multiple tags OK. Only use slugs listed; never invent. Full rules are in your memory instructions.`;
9
11
  function buildCriticalSection(params) {
10
12
  const { senderNumber, roleName, role, userName } = params;
11
13
  const who = userName
@@ -34,6 +36,7 @@ function buildCriticalSection(params) {
34
36
  }
35
37
  export function buildMemoryPreamble(params) {
36
38
  ensureScaffold();
39
+ ensureJournalsScaffold();
37
40
  const { name: roleName, role, userName } = getRoleForContext(params.senderNumber, params.isGroup ?? params.jid.endsWith('@g.us'));
38
41
  const sections = [];
39
42
  // Identity — tell Claude its name
@@ -108,7 +111,15 @@ export function buildMemoryPreamble(params) {
108
111
  if (entityBlocks.length) {
109
112
  sections.push(`${label}\n${entityBlocks.join('\n\n')}`);
110
113
  }
111
- sections.push(`[Instruction]\n${DIGEST_REMINDER}`);
114
+ // Journals — owner-scoped, shown globally across all chats.
115
+ const isOwner = !!config.owner.number && params.senderNumber === config.owner.number;
116
+ const journalsBlock = isOwner ? buildJournalsPreambleBlock() : null;
117
+ const instructions = [DIGEST_REMINDER];
118
+ if (journalsBlock) {
119
+ sections.push(`[Journals: active]\n${journalsBlock}`);
120
+ instructions.push(JOURNAL_REMINDER);
121
+ }
122
+ sections.push(`[Instruction]\n${instructions.join('\n\n')}`);
112
123
  return sections.join('\n\n');
113
124
  }
114
125
  function readIfExists(path) {
@@ -62,8 +62,20 @@ async function sweep() {
62
62
  .catch((err) => logger.error({ err, jid }, 'sweep digest push failed'));
63
63
  }
64
64
  }
65
+ // Journal observer pass: scans recent messages per active journal for
66
+ // entries Claude missed (i.e. when the bot wasn't mentioned). Runs once
67
+ // per sweep cycle; each journal maintains its own last-scanned-ts.
68
+ try {
69
+ const { runJournalObserverSweep } = await import('./journal-observer.js');
70
+ await runJournalObserverSweep();
71
+ }
72
+ catch (err) {
73
+ logger.error({ err }, 'journal observer sweep failed');
74
+ }
65
75
  }
66
76
  let sweepTimer = null;
77
+ let nudgeTimer = null;
78
+ const NUDGE_TICK_MS = 5 * 60 * 1000; // 5 minutes
67
79
  export function startScheduler() {
68
80
  if (sweepTimer)
69
81
  return;
@@ -72,13 +84,36 @@ export function startScheduler() {
72
84
  sweepTimer = setInterval(() => {
73
85
  void sweep().catch((err) => logger.error({ err }, 'sweep failed'));
74
86
  }, config.memory.sweepIntervalMs);
75
- logger.info({ intervalMs: config.memory.sweepIntervalMs }, 'memory scheduler started');
87
+ // Faster tick just for proactive journal nudges (check-ins, silent-nudges).
88
+ // The memory-sweep cycle (default 3h) is too coarse for a "daily 21:00"
89
+ // check-in. This tick is cheap: it only spawns Claude when something is
90
+ // actually due for a journal.
91
+ nudgeTimer = setInterval(() => {
92
+ void runNudgeTickSafe();
93
+ }, NUDGE_TICK_MS);
94
+ logger.info({
95
+ intervalMs: config.memory.sweepIntervalMs,
96
+ nudgeTickMs: NUDGE_TICK_MS,
97
+ }, 'memory scheduler started');
98
+ }
99
+ async function runNudgeTickSafe() {
100
+ try {
101
+ const { runNudgeTick } = await import('./journal-nudger.js');
102
+ await runNudgeTick();
103
+ }
104
+ catch (err) {
105
+ logger.error({ err }, 'nudge tick failed');
106
+ }
76
107
  }
77
108
  export function stopScheduler() {
78
109
  if (sweepTimer) {
79
110
  clearInterval(sweepTimer);
80
111
  sweepTimer = null;
81
112
  }
113
+ if (nudgeTimer) {
114
+ clearInterval(nudgeTimer);
115
+ nudgeTimer = null;
116
+ }
82
117
  for (const t of pendingTimers.values())
83
118
  clearTimeout(t);
84
119
  pendingTimers.clear();
@@ -1,7 +1,8 @@
1
1
  import { askClaude } from '../ai/claude.js';
2
2
  import { clearSession, setSession, setUsage } from '../ai/sessions.js';
3
3
  import { logger } from '../logger.js';
4
- import { extractDigestFlag } from '../memory/digest-flag.js';
4
+ import { extractFlags } from '../memory/digest-flag.js';
5
+ import { appendEntry, createJournal, getJournal, isValidSlug, updateJournalStatus, } from '../memory/journals.js';
5
6
  import { scheduleDigest } from '../memory/scheduler.js';
6
7
  function isStaleSessionError(err) {
7
8
  return (err instanceof Error &&
@@ -25,17 +26,73 @@ async function callClaude(job) {
25
26
  totalContextTokens,
26
27
  updatedAt: Math.floor(Date.now() / 1000),
27
28
  });
28
- const { clean, flag } = extractDigestFlag(reply);
29
- if (flag) {
30
- logger.info({ jid: job.jid, number: job.senderNumber, reason: flag }, 'DIGEST flag raised, scheduling');
29
+ const { clean, digest, journals, lifecycleOps } = extractFlags(reply);
30
+ if (digest) {
31
+ logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
31
32
  scheduleDigest({
32
33
  jid: job.jid,
33
34
  number: job.senderNumber,
34
- reason: flag,
35
+ reason: digest,
35
36
  });
36
37
  }
38
+ // Lifecycle ops run BEFORE entry appends so that a reply creating a new
39
+ // journal AND flagging its first entry in the same turn works correctly.
40
+ for (const op of lifecycleOps) {
41
+ if (!isValidSlug(op.slug)) {
42
+ logger.warn({ op, jid: job.jid }, 'journal lifecycle op: invalid slug, dropped');
43
+ continue;
44
+ }
45
+ try {
46
+ if (op.kind === 'new') {
47
+ if (getJournal(op.slug)) {
48
+ logger.info({ slug: op.slug }, 'JOURNAL-NEW for existing slug, ignored');
49
+ continue;
50
+ }
51
+ createJournal({
52
+ slug: op.slug,
53
+ name: titleCase(op.slug),
54
+ purpose: op.purpose,
55
+ });
56
+ logger.info({ slug: op.slug, jid: job.jid }, 'journal created via bot marker');
57
+ }
58
+ else {
59
+ const status = op.kind === 'pause'
60
+ ? 'paused'
61
+ : op.kind === 'archive'
62
+ ? 'archived'
63
+ : 'active';
64
+ const updated = updateJournalStatus(op.slug, status);
65
+ if (updated) {
66
+ logger.info({ slug: op.slug, status, jid: job.jid }, 'journal status updated via bot marker');
67
+ }
68
+ else {
69
+ logger.warn({ op, jid: job.jid }, 'journal lifecycle op: unknown slug, dropped');
70
+ }
71
+ }
72
+ }
73
+ catch (err) {
74
+ logger.error({ err, op, jid: job.jid }, 'journal lifecycle op failed');
75
+ }
76
+ }
77
+ for (const j of journals) {
78
+ const ok = appendEntry(j.slug, {
79
+ source: 'reactive',
80
+ jid: job.jid,
81
+ senderNumber: job.senderNumber,
82
+ note: j.note,
83
+ });
84
+ if (!ok) {
85
+ logger.warn({ slug: j.slug, jid: job.jid }, 'JOURNAL flag pointed at unknown slug, dropped');
86
+ }
87
+ }
37
88
  return { reply: clean };
38
89
  }
90
+ function titleCase(slug) {
91
+ return slug
92
+ .split('-')
93
+ .map((p) => (p ? p[0].toUpperCase() + p.slice(1) : p))
94
+ .join(' ');
95
+ }
39
96
  export async function processJob(job) {
40
97
  try {
41
98
  return await callClaude(job);
@@ -96,9 +96,11 @@ function save(next) {
96
96
  export function getAccess() {
97
97
  return current;
98
98
  }
99
- // Guardrail for proactive (unsolicited) messaging. Returns true ONLY if the
100
- // target chat has an explicit proactive:true entry. Default is deny — no
101
- // journal/scheduler/observer may message a chat without explicit opt-in.
99
+ // Guardrail for proactive (unsolicited) messaging. Default deny.
100
+ //
101
+ // Exception: the owner's own self-DM is always allowed — the owner implicitly
102
+ // consents to the bot nudging them in their own DM. Other DMs and groups
103
+ // require an explicit `proactive: true` entry in access.json.
102
104
  export function canSendProactive(jid) {
103
105
  const isGroup = jid.endsWith('@g.us');
104
106
  if (isGroup) {
@@ -108,6 +110,9 @@ export function canSendProactive(jid) {
108
110
  const number = jidDecode(jid)?.user;
109
111
  if (!number)
110
112
  return false;
113
+ // Owner's self-DM is always allowed.
114
+ if (config.owner.number && number === config.owner.number)
115
+ return true;
111
116
  const entry = current.dms.allowed.find((d) => d.number === number);
112
117
  return entry?.proactive === true;
113
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.1.18",
3
+ "version": "0.3.0",
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",