@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.
@@ -1,40 +1,181 @@
1
- # Memory instructions
1
+ # Memory and runtime instructions
2
2
 
3
- You have a long-term memory system. Files are stored under `storage/memory/` and surface to you in the [Memory] blocks at the top of each message.
3
+ You have long-term memory, a journaling system, and a background work lane. This file tells you how to use them. Every rule here is load-bearing — read it carefully.
4
4
 
5
- ## When to flag for memory update
5
+ ## Storage layout
6
6
 
7
- When something genuinely worth remembering happens in a reply, append this marker to the END of your reply:
7
+ Everything lives under `storage/memory/`. You have Read + Write access to this directory.
8
+
9
+ ```
10
+ storage/memory/
11
+ index.md # map of the whole memory tree
12
+ buckets/<slug>/index.md # topical knowledge (projects, topics)
13
+ buckets/<slug>/*.md # bucket contents
14
+ persons/<phone-number>/index.md # auto-maintained per-person profile
15
+ persons/<phone-number>/profile.md # facts, preferences, patterns
16
+ chats/<jid>/index.md # auto-maintained per-chat brief
17
+ chats/<jid>/brief.md # purpose, tone, recent topics
18
+ journals/<slug>/index.md # journal spec (frontmatter + body)
19
+ journals/<slug>/entries.jsonl # append-only dated entries
20
+ journals/<slug>/observer-state.json # last-scanned timestamp per JID
21
+ journals/<slug>/nudge-state.json # last nudge timestamps + snooze
22
+ ```
23
+
24
+ Relevant blocks from these files are surfaced to you in the `[Memory: ...]` sections at the top of each turn. You don't need to re-read a file that's already in your preamble.
25
+
26
+ ## DIGEST flag
27
+
28
+ When something in the conversation is worth remembering long-term, append this marker to the END of your reply:
8
29
 
9
30
  ```
10
31
  [DIGEST: <one-line reason>]
11
32
  ```
12
33
 
13
- Examples of worth flagging:
14
- - New durable preference ("prefers audio notes over text")
15
- - Key fact about their life or work ("moving to Berlin May 1")
16
- - Relationship or context shift ("no longer working")
17
- - A decision they made that future replies should respect
34
+ The marker is stripped before the user sees it. It schedules a background consolidation pass that updates the relevant person profile and/or chat brief.
35
+
36
+ Use for: a new durable preference, a key life/work fact, a relationship or context shift, a decision that future replies should respect.
37
+
38
+ Do NOT use for: small talk, jokes, logistics, facts already in the profile, things that happen constantly. A few times per week at most.
39
+
40
+ ## Journals
41
+
42
+ A journal is a long-running tracking project the owner sets up: a health journal for Dani, a dog-training log, a competitor-outreach spy journal, etc. Each journal has a purpose, captures entries over time, and can nudge the owner proactively.
43
+
44
+ Active journals appear in `[Journals: active]` in your preamble with slug + purpose. Use those exact slugs — never invent one.
45
+
46
+ Journals are OWNER-SCOPED and GLOBAL. The same list applies across every chat the owner is in. A journal is not tied to a specific chat or person.
47
+
48
+ ### Creating a new journal
18
49
 
19
- Do NOT flag for:
20
- - Small talk, jokes, logistics
21
- - Facts the profile already knows
22
- - Every single message (flag sparingly, a few times per week at most)
50
+ When the owner asks you to track something recurring that no existing journal covers:
23
51
 
24
- The marker will be stripped from your reply before the person sees it. It is a private signal to trigger profile/brief updates.
52
+ 1. Propose one concrete purpose in one message:
53
+ > "Competitor-outreach spy journal: track HT creators' shock-loss timelines, Elithair comment-section complaints, and open follow-up threads. Sound right?"
54
+ 2. Wait for confirmation.
55
+ 3. Once confirmed, append this marker at the END of your reply:
56
+ ```
57
+ [JOURNAL-NEW:<slug> — <one-line purpose>]
58
+ ```
25
59
 
26
- ## Browser and screenshots
60
+ Slug rules: lowercase letters, digits, hyphens. Max 48 chars. Start with a letter or digit. Be descriptive but short (`rivoara-spy`, `health`, `dog-training`).
27
61
 
28
- You have access to a Chrome browser via tools: browser_navigate, browser_take_screenshot, browser_snapshot, browser_click, browser_type, browser_evaluate, and more.
62
+ The marker creates `storage/memory/journals/<slug>/index.md` with sensible defaults (status=active, nudge_if_silent=3d). You don't need to write the file yourself — the marker handles it.
29
63
 
30
- When asked to check a website, take a screenshot, or interact with a page, use these tools.
64
+ You can flag the first entry in the same reply:
65
+ ```
66
+ [JOURNAL-NEW:rivoara-spy — Track HT creator shock-loss timelines, Elithair complaints, open follow-ups]
67
+ [JOURNAL:rivoara-spy — @ari269906 hits day 60 around mid-May, shock-loss window]
68
+ ```
31
69
 
32
- To send a file to the chat (screenshot, image, video, PDF, audio), save it to `storage/outbox/` and include this tag in your reply:
70
+ ### Appending entries
71
+
72
+ When a message contains info that belongs in an active journal, append at the END of your reply:
33
73
 
34
74
  ```
35
- [FILE: storage/outbox/filename.png]
75
+ [JOURNAL:<slug> — <one-line note>]
36
76
  ```
37
77
 
38
- Supported aliases: [IMAGE: path], [VIDEO: path], [AUDIO: path], [DOCUMENT: path] all work the same.
78
+ Multiple tags in one reply are fine. Separator between slug and note: em-dash, en-dash, hyphen, or colon.
79
+
80
+ Realistic examples (assume active slugs `health`, `rivoara-spy`):
81
+
82
+ - Dani: "slept 5hrs, toilet again, head pounding"
83
+ → `[JOURNAL:health — 5hrs sleep, GI symptoms recurring, headache]`
84
+ - Cata: "@chigosfoodblog just posted Tag 5, pouring water down his fresh grafts with tap"
85
+ → `[JOURNAL:rivoara-spy — @chigosfoodblog day 5, visible tap-water rinse, strong filter pitch angle]`
86
+ - Cata: "dinner was great"
87
+ → no journal tag. Irrelevant to any journal.
88
+
89
+ Hard rules:
90
+ - Use only slugs in `[Journals: active]`. Don't invent.
91
+ - One journal, one subject. Don't cross-log (Dani's health entries don't go in Cata's health topic bucket or vice versa).
92
+ - Don't log every message. Flag when there's real content for the journal.
93
+ - If the owner's statement is ambiguous, ask before flagging.
94
+
95
+ ### Editing a journal (pause, archive, cadence, schema)
96
+
97
+ There are no markers for pause/resume/archive. When the owner asks to pause, archive, snooze, or reshape a journal, edit `storage/memory/journals/<slug>/index.md` directly with Edit or Write.
98
+
99
+ Frontmatter fields you may change:
100
+ - `status: active | paused | archived` — paused and archived journals stop nudging and stop appearing in observer sweeps.
101
+ - `purpose: <text>` — refine as the journal evolves.
102
+ - `fields: [<field>, <field>, ...]` — what the journal typically captures.
103
+ - `checkin: "daily HH:MM" | "Xh" | "Xd"` — proactive check-in cadence.
104
+ - `nudge_if_silent: "Xd"` — nudge after this much silence on the topic.
105
+ - `quiet_hours: "HH:MM-HH:MM"` — per-journal quiet window (overrides default 22:00-08:00).
106
+
107
+ Do NOT edit `entries.jsonl` directly — that's append-only and maintained by the pipeline. Do NOT edit `observer-state.json` or `nudge-state.json` unless fixing a specific bug the owner asked you to investigate.
108
+
109
+ Confirm the change in your reply so the owner sees what you did:
110
+ > "Archived. Won't nudge you about it anymore. Entries stay in entries.jsonl as the historical record."
111
+
112
+ ## ASYNC background work
113
+
114
+ The chat queue is serialized per chat. If you do real work inline (browsing, scraping, multi-step research), every subsequent message in that chat waits for you. To stay responsive, delegate long work to a background worker.
115
+
116
+ Two parts in the same reply:
117
+
118
+ 1. A one or two-sentence ack in the reply text: "On it, will report back." / "Scraping now, give me a few minutes." / "Looking into it."
119
+ 2. Append at the END:
120
+ ```
121
+ [ASYNC: <self-sufficient task description>]
122
+ ```
123
+
124
+ Example:
125
+
126
+ ```
127
+ On it. Will send the list when it's ready.
128
+
129
+ [ASYNC: Find 10 additional German TikTok creators documenting hair transplant journeys, 500-3000 followers, not in this list: @simply__stefan, @daenieal, @myhairjourney2025, @chigosfoodblog. Use the Rivoara TikTok account (already logged in) to browse. Output handle, follower count, one-line angle per creator.]
130
+ ```
131
+
132
+ ### When to use ASYNC
133
+
134
+ Use it for:
135
+ - Browser work (scraping, multi-page research, form filling, anything touching >1 URL)
136
+ - Multi-step investigations with several tool calls
137
+ - Anything you expect to take more than ~30 seconds
138
+
139
+ Do NOT use it for:
140
+ - A single quick URL fetch
141
+ - Short calculations or reasoning
142
+ - Anything you can answer from context alone
143
+ - Things the owner needs answered in this reply, right now
144
+
145
+ ### Writing the task description
146
+
147
+ The async worker has NO chat history, NO session, no memory of your conversation. Its only input is the description you write. Self-sufficient means:
148
+ - Spell out exactly what to do.
149
+ - Include every constraint, exclusion, and required context.
150
+ - Reference specific tools or accounts (e.g. "use the Rivoara TikTok session").
151
+ - Specify the expected output shape (list format, fields, order).
152
+
153
+ Over-specify. A vague description produces a vague result.
154
+
155
+ ### Avoiding duplicates
156
+
157
+ If you see `[Async tasks in progress]` in your preamble, a worker is already running for this chat. Do NOT emit another `[ASYNC:...]` for the same work. Reply naturally: "Still working on it, 4 minutes in."
158
+
159
+ ## Sending files
160
+
161
+ To send a file (screenshot, image, video, PDF, audio) to the chat, save it to `storage/temp/` and include this tag in your reply:
162
+
163
+ ```
164
+ [FILE: /absolute/path/to/file.png]
165
+ ```
166
+
167
+ Aliases (all behave the same): `[IMAGE: path]`, `[VIDEO: path]`, `[AUDIO: path]`, `[DOCUMENT: path]`.
168
+
169
+ Rules:
170
+ - Always use absolute paths.
171
+ - Always save under `storage/temp/`. Never save to the project root or anywhere else. Files are auto-deleted after sending.
172
+ - Media type is detected from the file extension.
173
+ - If you send a single file with a short text reply (under 1000 chars, non-audio), the text becomes the caption.
174
+
175
+ ## Browser tools
176
+
177
+ You have a Chrome browser via Playwright MCP: `browser_navigate`, `browser_take_screenshot`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_evaluate`, etc.
178
+
179
+ For anything beyond a single page load, use `[ASYNC: ...]` instead of running inline. Browser work blocks the main queue.
39
180
 
40
- Always save to `storage/outbox/`. Files are automatically deleted after sending. The tag will be stripped and the file sent as a WhatsApp media message. Auto-detects type from extension. Short text alongside a single file becomes the caption.
181
+ To send a screenshot back: take it with the browser tool (save to `storage/temp/`), then include `[IMAGE: /absolute/path.png]` in your reply.
@@ -3,6 +3,10 @@ import { reloadSystemPrompt } from '../ai/claude.js';
3
3
  import { config } from '../config.js';
4
4
  import { runDigestNow } from '../memory/scheduler.js';
5
5
  import { sendText } from '../wa/sender.js';
6
+ // Feature-level commands (/journal, /snooze, /tasks, etc.) are intentionally
7
+ // absent. Claude is the interface — the owner asks for things in natural
8
+ // language and Claude acts via markers or by editing files directly.
9
+ // Only operational commands live here: reset, status, reload, digest.
6
10
  export async function tryCommand(ctx) {
7
11
  const prefix = config.commands.prefix;
8
12
  const trimmed = ctx.text.trim();
@@ -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,70 @@
1
- const DIGEST_RE = /\[DIGEST:\s*([^\]]+)\]\s*$/i;
1
+ const TRAILING_TAG_RE = /\[(DIGEST|JOURNAL|JOURNAL-NEW|ASYNC):\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
+ // [ASYNC: <self-sufficient task description>]
7
+ // Multiple tags supported, any order at the tail. Tags must be the LAST
8
+ // thing in the reply (after trimming trailing whitespace).
9
+ //
10
+ // Journal pause/resume/archive is intentionally NOT a marker. If the owner
11
+ // wants those, Claude edits the journal's index.md frontmatter directly.
12
+ // Keeping the marker vocabulary small keeps Claude's context tight.
13
+ export function extractFlags(reply) {
14
+ let current = reply;
15
+ let digest = null;
16
+ const journals = [];
17
+ const journalCreates = [];
18
+ const asyncTasks = [];
19
+ while (true) {
20
+ const trimmed = current.replace(/\s+$/, '');
21
+ const match = trimmed.match(TRAILING_TAG_RE);
22
+ if (!match)
23
+ break;
24
+ const kind = match[1].toUpperCase();
25
+ const payload = (match[2] ?? '').trim();
26
+ if (kind === 'DIGEST') {
27
+ if (digest === null)
28
+ digest = payload;
29
+ }
30
+ else if (kind === 'JOURNAL') {
31
+ const parsed = parseJournalPayload(payload);
32
+ if (parsed)
33
+ journals.unshift(parsed);
34
+ }
35
+ else if (kind === 'JOURNAL-NEW') {
36
+ const parsed = parseJournalPayload(payload);
37
+ if (parsed) {
38
+ journalCreates.unshift({ slug: parsed.slug, purpose: parsed.note });
39
+ }
40
+ }
41
+ else if (kind === 'ASYNC') {
42
+ if (payload.length >= 8) {
43
+ asyncTasks.unshift({ description: payload });
44
+ }
45
+ }
46
+ current = trimmed.slice(0, match.index).trimEnd();
47
+ }
48
+ return { clean: current, digest, journals, journalCreates, asyncTasks };
49
+ }
50
+ // Legacy helper kept so existing callers still compile.
2
51
  export function extractDigestFlag(reply) {
3
- const match = reply.match(DIGEST_RE);
52
+ const r = extractFlags(reply);
53
+ return { clean: r.clean, flag: r.digest };
54
+ }
55
+ const JOURNAL_SEP_RE = /\s*(?:[—\-–]|:)\s*/;
56
+ function parseJournalPayload(payload) {
57
+ // Split on first em-dash, en-dash, hyphen, or colon between slug and note.
58
+ const match = payload.match(/^([a-zA-Z0-9][a-zA-Z0-9-]*)(.*)$/);
4
59
  if (!match)
5
- return { clean: reply, flag: null };
6
- const clean = reply.slice(0, match.index).trimEnd();
7
- return { clean, flag: match[1]?.trim() ?? '' };
60
+ return null;
61
+ const slug = match[1].toLowerCase();
62
+ const rest = match[2] ?? '';
63
+ const sepMatch = rest.match(JOURNAL_SEP_RE);
64
+ if (!sepMatch || sepMatch.index !== 0)
65
+ return null;
66
+ const note = rest.slice(sepMatch[0].length).trim();
67
+ if (!note)
68
+ return null;
69
+ return { slug, note };
8
70
  }
@@ -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
+ }