@c4t4/heyamigo 0.3.0 → 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,121 +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.
18
35
 
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)
36
+ Use for: a new durable preference, a key life/work fact, a relationship or context shift, a decision that future replies should respect.
23
37
 
24
- The marker will be stripped from your reply before the person sees it. It is a private signal to trigger profile/brief updates.
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.
25
39
 
26
40
  ## Journals
27
41
 
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.
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.
29
45
 
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.
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.
31
47
 
32
- ### When to flag a journal entry
48
+ ### Creating a new journal
33
49
 
34
- When a message contains info that belongs in one of the active journals, append a marker to the END of your reply:
50
+ When the owner asks you to track something recurring that no existing journal covers:
35
51
 
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
+ ```
59
+
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`).
61
+
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.
63
+
64
+ You can flag the first entry in the same reply:
36
65
  ```
37
- [JOURNAL:<slug><one-line note>]
66
+ [JOURNAL-NEW:rivoara-spyTrack HT creator shock-loss timelines, Elithair complaints, open follow-ups]
67
+ [JOURNAL:rivoara-spy — @ari269906 hits day 60 around mid-May, shock-loss window]
38
68
  ```
39
69
 
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.
70
+ ### Appending entries
41
71
 
42
- Separator between slug and note can be em-dash, en-dash, hyphen, or colon.
72
+ When a message contains info that belongs in an active journal, append at the END of your reply:
43
73
 
44
- Examples (assuming `health` and `training` are active slugs):
74
+ ```
75
+ [JOURNAL:<slug> — <one-line note>]
76
+ ```
77
+
78
+ Multiple tags in one reply are fine. Separator between slug and note: em-dash, en-dash, hyphen, or colon.
45
79
 
46
- - Owner: "slept 5hrs, mild headache again"
47
- Reply ends with: `[JOURNAL:health — 5hrs sleep, mild headache]`
80
+ Realistic examples (assume active slugs `health`, `rivoara-spy`):
48
81
 
49
- - Owner: "Biscuit finally learned 'stay' for 30 seconds today!"
50
- Reply ends with: `[JOURNAL:trainingBiscuit held 'stay' for 30s]`
82
+ - Dani: "slept 5hrs, toilet again, head pounding"
83
+ `[JOURNAL:health5hrs 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.
51
88
 
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]`
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.
54
94
 
55
- ### Hard rules
95
+ ### Editing a journal (pause, archive, cadence, schema)
56
96
 
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.
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.
61
98
 
62
- ### Proactive engagement in-conversation
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).
63
106
 
64
- Journals exist to keep the owner engaged over time. When you're responding and a journal is relevant, you may:
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.
65
108
 
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?"
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."
69
111
 
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.
112
+ ## ASYNC background work
71
113
 
72
- ### Setting up a new journal
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.
73
115
 
74
- When the owner says something like "start a health journal":
116
+ Two parts in the same reply:
75
117
 
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:
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:
80
120
  ```
81
- [JOURNAL-NEW:<slug> <one-line purpose>]
121
+ [ASYNC: <self-sufficient task description>]
82
122
  ```
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
123
 
85
- Example end of reply:
124
+ Example:
125
+
86
126
  ```
87
- [JOURNAL-NEW:health Track sleep, symptoms, meds, mood]
88
- [JOURNAL:health — 5hrs sleep, mild headache]
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.]
89
130
  ```
90
131
 
91
- Slug rules: lowercase letters, digits, hyphens. Max 48 chars. Must start with a letter or digit.
132
+ ### When to use ASYNC
92
133
 
93
- Be opinionated about the purpose. Don't ask ten questions; pick reasonable defaults and let them tweak.
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
94
138
 
95
- ### Pausing, resuming, archiving
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
96
144
 
97
- The owner can also ask you to pause/archive/resume a journal. Emit one of these markers:
145
+ ### Writing the task description
98
146
 
99
- ```
100
- [JOURNAL-PAUSE:<slug>]
101
- [JOURNAL-RESUME:<slug>]
102
- [JOURNAL-ARCHIVE:<slug>]
103
- ```
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).
104
152
 
105
- Do this only when the owner asks. Never pause or archive a journal on your own judgment.
153
+ Over-specify. A vague description produces a vague result.
106
154
 
107
- ## Browser and screenshots
155
+ ### Avoiding duplicates
108
156
 
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.
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."
110
158
 
111
- When asked to check a website, take a screenshot, or interact with a page, use these tools.
159
+ ## Sending files
112
160
 
113
- To send a file to the chat (screenshot, image, video, PDF, audio), save it to `storage/outbox/` and include this tag in your reply:
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:
114
162
 
115
163
  ```
116
- [FILE: storage/outbox/filename.png]
164
+ [FILE: /absolute/path/to/file.png]
117
165
  ```
118
166
 
119
- Supported aliases: [IMAGE: path], [VIDEO: path], [AUDIO: path], [DOCUMENT: path] — all work the same.
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.
120
180
 
121
- 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.
@@ -1,9 +1,12 @@
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';
5
4
  import { runDigestNow } from '../memory/scheduler.js';
6
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.
7
10
  export async function tryCommand(ctx) {
8
11
  const prefix = config.commands.prefix;
9
12
  const trimmed = ctx.text.trim();
@@ -55,199 +58,5 @@ export async function tryCommand(ctx) {
55
58
  }).catch(() => undefined);
56
59
  return true;
57
60
  }
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
- }
76
61
  return false;
77
62
  }
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
- }
@@ -1,18 +1,21 @@
1
- const TRAILING_TAG_RE = /\[(DIGEST|JOURNAL|JOURNAL-NEW|JOURNAL-PAUSE|JOURNAL-RESUME|JOURNAL-ARCHIVE):\s*([^\]]+)\]\s*$/i;
1
+ const TRAILING_TAG_RE = /\[(DIGEST|JOURNAL|JOURNAL-NEW|ASYNC):\s*([^\]]+)\]\s*$/i;
2
2
  // Peel trailing tags off the end of a reply. Supported:
3
3
  // [DIGEST: <reason>]
4
4
  // [JOURNAL:<slug> — <note>] (append entry)
5
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).
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.
11
13
  export function extractFlags(reply) {
12
14
  let current = reply;
13
15
  let digest = null;
14
16
  const journals = [];
15
- const lifecycleOps = [];
17
+ const journalCreates = [];
18
+ const asyncTasks = [];
16
19
  while (true) {
17
20
  const trimmed = current.replace(/\s+$/, '');
18
21
  const match = trimmed.match(TRAILING_TAG_RE);
@@ -32,29 +35,17 @@ export function extractFlags(reply) {
32
35
  else if (kind === 'JOURNAL-NEW') {
33
36
  const parsed = parseJournalPayload(payload);
34
37
  if (parsed) {
35
- lifecycleOps.unshift({
36
- kind: 'new',
37
- slug: parsed.slug,
38
- purpose: parsed.note,
39
- });
38
+ journalCreates.unshift({ slug: parsed.slug, purpose: parsed.note });
40
39
  }
41
40
  }
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 });
41
+ else if (kind === 'ASYNC') {
42
+ if (payload.length >= 8) {
43
+ asyncTasks.unshift({ description: payload });
53
44
  }
54
45
  }
55
46
  current = trimmed.slice(0, match.index).trimEnd();
56
47
  }
57
- return { clean: current, digest, journals, lifecycleOps };
48
+ return { clean: current, digest, journals, journalCreates, asyncTasks };
58
49
  }
59
50
  // Legacy helper kept so existing callers still compile.
60
51
  export function extractDigestFlag(reply) {
@@ -304,11 +304,3 @@ export function saveNudgeState(slug, state) {
304
304
  ensureDirFor(path);
305
305
  writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
306
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,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import { config } from '../config.js';
4
+ import { listAsyncTasks } from '../queue/async-tasks.js';
4
5
  import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
5
6
  import { masterIndexPath, treeIndexPath } from './paths.js';
6
7
  import { routeIndexes } from './router.js';
@@ -119,6 +120,19 @@ export function buildMemoryPreamble(params) {
119
120
  sections.push(`[Journals: active]\n${journalsBlock}`);
120
121
  instructions.push(JOURNAL_REMINDER);
121
122
  }
123
+ // Async tasks in progress for this chat — so Claude doesn't re-promise or
124
+ // contradict work already running in the background.
125
+ const asyncTasks = listAsyncTasks(params.jid);
126
+ if (asyncTasks.length > 0) {
127
+ const now = Math.floor(Date.now() / 1000);
128
+ const lines = ['You have background tasks currently running for this chat:'];
129
+ for (const t of asyncTasks) {
130
+ const ageSec = Math.max(0, now - t.startedAt);
131
+ lines.push(`- "${t.description}" (started ${formatAge(ageSec)} ago)`);
132
+ }
133
+ lines.push('', 'Do NOT re-start or re-promise these. Reply referencing that they are in progress if relevant, but do not emit another [ASYNC:...] for the same work.');
134
+ sections.push(`[Async tasks in progress]\n${lines.join('\n')}`);
135
+ }
122
136
  sections.push(`[Instruction]\n${instructions.join('\n\n')}`);
123
137
  return sections.join('\n\n');
124
138
  }
@@ -127,6 +141,13 @@ function readIfExists(path) {
127
141
  return null;
128
142
  return readFileSync(path, 'utf-8');
129
143
  }
144
+ function formatAge(seconds) {
145
+ if (seconds < 60)
146
+ return `${seconds}s`;
147
+ if (seconds < 3600)
148
+ return `${Math.floor(seconds / 60)}m`;
149
+ return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60)}m`;
150
+ }
130
151
  function buildTimeLine(timezone) {
131
152
  const now = new Date();
132
153
  const fmt = new Intl.DateTimeFormat('en-GB', {
@@ -0,0 +1,215 @@
1
+ import { spawn } from 'child_process';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { config } from '../config.js';
5
+ import fastq from 'fastq';
6
+ import { initiate } from '../gateway/outgoing.js';
7
+ import { logger } from '../logger.js';
8
+ import { logPrompt } from '../promptlog.js';
9
+ // Concurrency: how many async Claude workers can run simultaneously.
10
+ // Start conservative — each process is expensive (Playwright, multi-minute runs).
11
+ // Tune via config.asyncTasks.concurrency once we have real usage data.
12
+ const CONCURRENCY = 3;
13
+ // In-memory registry of tasks currently executing. Not persisted across
14
+ // restarts — on reboot, any in-flight async work is silently dropped.
15
+ // We expose listInProgress() so the chat preamble can show "in progress"
16
+ // hints to the main Claude.
17
+ const inProgress = new Map();
18
+ const queue = fastq.promise(async (task) => {
19
+ inProgress.set(task.id, task);
20
+ try {
21
+ await runTask(task);
22
+ }
23
+ catch (err) {
24
+ logger.error({ err, id: task.id, jid: task.jid }, 'async task failed unexpectedly');
25
+ }
26
+ finally {
27
+ inProgress.delete(task.id);
28
+ }
29
+ }, CONCURRENCY);
30
+ export function enqueueAsyncTask(input) {
31
+ const task = {
32
+ ...input,
33
+ id: `async-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
34
+ startedAt: Math.floor(Date.now() / 1000),
35
+ };
36
+ logger.info({
37
+ id: task.id,
38
+ jid: task.jid,
39
+ description: task.description.slice(0, 200),
40
+ }, 'async task enqueued');
41
+ queue.push(task).catch((err) => logger.error({ err, id: task.id }, 'async queue push failed'));
42
+ return task;
43
+ }
44
+ export function listAsyncTasks(jid) {
45
+ const all = Array.from(inProgress.values());
46
+ if (!jid)
47
+ return all;
48
+ return all.filter((t) => t.jid === jid);
49
+ }
50
+ // ---------- task runner ----------
51
+ let cachedSystemPrompt = null;
52
+ function systemPrompt() {
53
+ if (cachedSystemPrompt !== null)
54
+ return cachedSystemPrompt;
55
+ const personality = readFileSync(resolve(process.cwd(), config.claude.personalityFile), 'utf-8');
56
+ let memoryInstructions = '';
57
+ try {
58
+ memoryInstructions = readFileSync(resolve(process.cwd(), config.memory.instructionsFile), 'utf-8');
59
+ }
60
+ catch {
61
+ // optional
62
+ }
63
+ cachedSystemPrompt = memoryInstructions
64
+ ? `${personality}\n\n---\n\n${memoryInstructions}`
65
+ : personality;
66
+ return cachedSystemPrompt;
67
+ }
68
+ export function reloadAsyncSystemPrompt() {
69
+ cachedSystemPrompt = null;
70
+ }
71
+ function buildPrompt(task) {
72
+ const lines = [
73
+ `You are running a BACKGROUND TASK for the owner. The chat already got your ack reply. Your only job now is to do the work and output the final message to send them.`,
74
+ ``,
75
+ `TASK:`,
76
+ task.description,
77
+ ``,
78
+ `ORIGINAL USER MESSAGE (for reference):`,
79
+ task.originatingMessage,
80
+ ``,
81
+ `Sender: ${task.senderName ?? task.senderNumber}`,
82
+ ``,
83
+ `RULES:`,
84
+ `- Stay fully in character (personality file). This is not customer service.`,
85
+ `- Do the real work. Use tools (browser, etc.) as needed.`,
86
+ `- When done, output ONLY the message to send the user. No preamble, no "here's what I found:" framing unless that's the message itself.`,
87
+ `- Do NOT emit any [DIGEST:...], [JOURNAL:...], [ASYNC:...], or other markers. This is the final output.`,
88
+ `- Start the message with a short reference to what you were working on so the user knows which task this is about (e.g. "About the TikTok scrape: ..."). They may have asked for multiple things.`,
89
+ `- If the task is impossible or the tools failed, say so honestly and briefly. Don't fabricate.`,
90
+ ``,
91
+ `Output the final user-facing message now.`,
92
+ ];
93
+ return lines.join('\n');
94
+ }
95
+ function buildArgs(task) {
96
+ const args = [
97
+ '-p',
98
+ '--output-format',
99
+ 'json',
100
+ '--model',
101
+ config.claude.model,
102
+ '--permission-mode',
103
+ 'acceptEdits',
104
+ '--append-system-prompt',
105
+ systemPrompt(),
106
+ ];
107
+ for (const dir of config.claude.addDirs) {
108
+ args.push('--add-dir', resolve(process.cwd(), dir));
109
+ }
110
+ args.push('--add-dir', resolve(process.cwd(), config.memory.dir));
111
+ args.push('--add-dir', resolve(process.cwd(), config.storage.mediaDir));
112
+ if (task.allowedTools &&
113
+ task.allowedTools !== 'all' &&
114
+ task.allowedTools.length > 0) {
115
+ args.push('--allowedTools', task.allowedTools.join(','));
116
+ }
117
+ return args;
118
+ }
119
+ async function spawnClaudeForTask(task, prompt) {
120
+ const args = buildArgs(task);
121
+ const startedAt = Date.now();
122
+ return new Promise((resolvePromise, rejectPromise) => {
123
+ const child = spawn('claude', args, {
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ cwd: process.cwd(),
126
+ });
127
+ let stdout = '';
128
+ let stderr = '';
129
+ child.stdout.on('data', (c) => {
130
+ stdout += c.toString('utf-8');
131
+ });
132
+ child.stderr.on('data', (c) => {
133
+ stderr += c.toString('utf-8');
134
+ });
135
+ const logFail = (error) => void logPrompt({
136
+ ts: Math.floor(startedAt / 1000),
137
+ caller: 'async-task',
138
+ args,
139
+ input: prompt,
140
+ error,
141
+ durationMs: Date.now() - startedAt,
142
+ });
143
+ child.on('error', (err) => {
144
+ logFail(`spawn failed: ${err.message}`);
145
+ rejectPromise(err);
146
+ });
147
+ child.on('close', (code) => {
148
+ if (code !== 0) {
149
+ logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
150
+ return rejectPromise(new Error(`async task exit ${code}`));
151
+ }
152
+ try {
153
+ const parsed = JSON.parse(stdout);
154
+ if (parsed.is_error ||
155
+ parsed.subtype !== 'success' ||
156
+ !parsed.result) {
157
+ logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
158
+ return rejectPromise(new Error('async task bad output'));
159
+ }
160
+ const output = parsed.result.trim();
161
+ void logPrompt({
162
+ ts: Math.floor(startedAt / 1000),
163
+ caller: 'async-task',
164
+ args,
165
+ input: prompt,
166
+ output,
167
+ durationMs: Date.now() - startedAt,
168
+ });
169
+ resolvePromise(output);
170
+ }
171
+ catch (err) {
172
+ logFail(`parse failed: ${err.message}`);
173
+ rejectPromise(err);
174
+ }
175
+ });
176
+ child.stdin.write(prompt);
177
+ child.stdin.end();
178
+ });
179
+ }
180
+ async function runTask(task) {
181
+ const prompt = buildPrompt(task);
182
+ const elapsedLog = () => `${Math.round((Date.now() - task.startedAt * 1000) / 1000)}s`;
183
+ let output;
184
+ try {
185
+ output = await spawnClaudeForTask(task, prompt);
186
+ }
187
+ catch (err) {
188
+ logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'async task claude call failed');
189
+ await initiate({
190
+ jid: task.jid,
191
+ text: `Heads up: the background task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
192
+ });
193
+ return;
194
+ }
195
+ // Strip any accidental trailing markers Claude emitted despite instructions.
196
+ // Import lazily to avoid an import cycle (digest-flag already stands alone,
197
+ // but being explicit here keeps this module independent).
198
+ const { extractFlags } = await import('../memory/digest-flag.js');
199
+ const { clean } = extractFlags(output);
200
+ if (!clean.trim()) {
201
+ logger.warn({ id: task.id, jid: task.jid }, 'async task produced empty output after flag strip');
202
+ return;
203
+ }
204
+ const sent = await initiate({ jid: task.jid, text: clean });
205
+ logger.info({
206
+ id: task.id,
207
+ jid: task.jid,
208
+ sent,
209
+ elapsed: elapsedLog(),
210
+ chars: clean.length,
211
+ }, 'async task completed');
212
+ }
213
+ function truncate(s, n) {
214
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
215
+ }
@@ -2,8 +2,9 @@ import { askClaude } from '../ai/claude.js';
2
2
  import { clearSession, setSession, setUsage } from '../ai/sessions.js';
3
3
  import { logger } from '../logger.js';
4
4
  import { extractFlags } from '../memory/digest-flag.js';
5
- import { appendEntry, createJournal, getJournal, isValidSlug, updateJournalStatus, } from '../memory/journals.js';
5
+ import { appendEntry, createJournal, getJournal, isValidSlug, } from '../memory/journals.js';
6
6
  import { scheduleDigest } from '../memory/scheduler.js';
7
+ import { enqueueAsyncTask } from './async-tasks.js';
7
8
  function isStaleSessionError(err) {
8
9
  return (err instanceof Error &&
9
10
  err.message.includes('No conversation found'));
@@ -26,7 +27,7 @@ async function callClaude(job) {
26
27
  totalContextTokens,
27
28
  updatedAt: Math.floor(Date.now() / 1000),
28
29
  });
29
- const { clean, digest, journals, lifecycleOps } = extractFlags(reply);
30
+ const { clean, digest, journals, journalCreates, asyncTasks } = extractFlags(reply);
30
31
  if (digest) {
31
32
  logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
32
33
  scheduleDigest({
@@ -35,43 +36,27 @@ async function callClaude(job) {
35
36
  reason: digest,
36
37
  });
37
38
  }
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) {
39
+ // Creates run BEFORE entry appends so that a reply creating a new journal
40
+ // AND flagging its first entry in the same turn works correctly.
41
+ for (const op of journalCreates) {
41
42
  if (!isValidSlug(op.slug)) {
42
- logger.warn({ op, jid: job.jid }, 'journal lifecycle op: invalid slug, dropped');
43
+ logger.warn({ op, jid: job.jid }, 'JOURNAL-NEW: invalid slug, dropped');
43
44
  continue;
44
45
  }
45
46
  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
- }
47
+ if (getJournal(op.slug)) {
48
+ logger.info({ slug: op.slug }, 'JOURNAL-NEW for existing slug, ignored');
49
+ continue;
71
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');
72
57
  }
73
58
  catch (err) {
74
- logger.error({ err, op, jid: job.jid }, 'journal lifecycle op failed');
59
+ logger.error({ err, op, jid: job.jid }, 'JOURNAL-NEW failed');
75
60
  }
76
61
  }
77
62
  for (const j of journals) {
@@ -85,6 +70,19 @@ async function callClaude(job) {
85
70
  logger.warn({ slug: j.slug, jid: job.jid }, 'JOURNAL flag pointed at unknown slug, dropped');
86
71
  }
87
72
  }
73
+ // Async tasks: Claude delegated long work (browser scrapes, multi-step
74
+ // research, etc.) to the background lane. The clean reply above is the
75
+ // user-facing ack and will be sent normally. The async tasks run stateless
76
+ // in their own queue and report back via initiate() when done.
77
+ for (const t of asyncTasks) {
78
+ enqueueAsyncTask({
79
+ jid: job.jid,
80
+ senderNumber: job.senderNumber,
81
+ description: t.description,
82
+ originatingMessage: job.text,
83
+ allowedTools: job.allowedTools ?? 'all',
84
+ });
85
+ }
88
86
  return { reply: clean };
89
87
  }
90
88
  function titleCase(slug) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",