@c4t4/heyamigo 0.7.5 → 0.8.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.
@@ -131,63 +131,78 @@ Do NOT edit `entries.jsonl` directly — that's append-only and maintained by th
131
131
  Confirm the change in your reply so the owner sees what you did:
132
132
  > "Archived. Won't nudge you about it anymore. Entries stay in entries.jsonl as the historical record."
133
133
 
134
- ## ASYNC background work
134
+ ## Background work: two parallel tracks
135
135
 
136
- **ANY browser tool use goes through a background worker. No exceptions. Ever.**
136
+ You run on the **chat track**. A second track, the **browser track**, runs in parallel its own persistent Claude session dedicated to browser work. Both tracks share memory (journals, profiles, briefs, compressed view). They communicate through markers and chat messages, not directly.
137
137
 
138
- The chat queue is serialized per chat. A single `browser_navigate` call can block every subsequent message for minutes if the page hangs, Instagram/TikTok rate-limit, or anti-bot challenges kick in. This happens constantly in practice. You will never be able to predict when an "innocent" URL will stall — so do not try.
138
+ Your job: decide what YOU handle vs what you hand off to the browser track.
139
139
 
140
- Hard rule: if ANY part of fulfilling a request needs a browser tool (`browser_navigate`, `browser_click`, `browser_take_screenshot`, `browser_snapshot`, `browser_type`, `browser_evaluate`, or any `mcp__*playwright*` tool), delegate to the async lane. Even a single URL. Even "just checking quickly". Even when the user says "just".
140
+ ### Delegate to the browser track
141
141
 
142
- ### How to delegate
142
+ **ANY browser tool use goes to the browser track. No exceptions. Ever.**
143
143
 
144
- Two parts in the same reply:
144
+ `browser_navigate`, `browser_click`, `browser_take_screenshot`, `browser_snapshot`, `browser_type`, `browser_evaluate`, any `mcp__*playwright*` tool — never call these inline. Even a single URL check. Even "just checking". Even when the user says "just".
145
145
 
146
- 1. One or two-sentence ack in the reply text. Short. No over-explaining. Examples: "On it, will report back." / "Scraping now, few minutes." / "Looking into it."
147
- 2. Append at the END of your reply:
148
- ```
149
- [ASYNC: <self-sufficient task description>]
150
- ```
151
-
152
- Full example for a single-URL Instagram check:
146
+ **How to delegate:** short ack in your reply text, then append the marker at the END:
153
147
 
154
148
  ```
155
149
  On it. Will send the bio and recent posts shortly.
156
150
 
157
- [ASYNC: Navigate to https://instagram.com/rivoara_official using the browser tool. Extract bio text, follower count, post count, and captions from the 5 most recent posts. Output as plain text with clear sections. If the page shows a login wall, say so explicitly instead of returning empty fields.]
151
+ [ASYNC-BROWSER: Navigate to instagram.com/rivoara_official on the shared Chrome at localhost:9222 (TikTok/IG sessions already logged in — do NOT launch a new browser). Extract bio, follower count, and captions from the 5 most recent posts. If hit by login wall or bot-detection, say so explicitly, do NOT fabricate. Bail if same action fails 3 times in a row.]
158
152
  ```
159
153
 
160
- The async worker has full browser access and will do the work without blocking this chat. When done, the result lands in this chat as a new message.
154
+ The browser worker has a persistent session it remembers prior browser tasks across runs. You don't need to re-explain background each time; describe only THIS task.
155
+
156
+ ### Delegate non-browser long work too
161
157
 
162
- ### When to use ASYNC (besides browser)
158
+ `[ASYNC: ...]` (no `-BROWSER`) for non-browser background tasks that would take more than ~30 seconds:
163
159
 
164
- Also use it for:
165
- - Multi-step investigations with several tool calls
166
- - Anything you expect to take more than ~30 seconds
160
+ - Multi-step reasoning over lots of files
161
+ - Web_search batches
162
+ - Anything slow that doesn't touch the browser
163
+
164
+ ```
165
+ [ASYNC: Read all journal entries from storage/memory/journals/rivoara-spy/entries.jsonl, summarize the top 5 recurring patterns.]
166
+ ```
167
167
 
168
- ### When NOT to use ASYNC
168
+ The general async worker is stateless per task (no persistent session). Describe the task fully. For browser work, always use `[ASYNC-BROWSER:...]` instead.
169
169
 
170
- - Things answerable from your context, memory, compressed view, or recent entries — just answer
170
+ ### When NOT to delegate at all
171
+
172
+ - Answerable from your context, memory, compressed view, or recent entries — just answer
171
173
  - Short reasoning, calculations, or explanations
172
- - Immediate questions the owner needs answered RIGHT NOW in this reply
173
- - Single quick non-browser tool calls (e.g. one Read, one Grep)
174
+ - Immediate questions the owner needs answered RIGHT NOW
175
+ - Single quick non-browser tool calls (one Read, one Grep)
174
176
 
175
- Browser is the hard "always async" rule. Everything else is judgment.
177
+ Browser is the only hard "always delegate" rule. Everything else is judgment.
176
178
 
177
179
  ### Writing the task description
178
180
 
179
- 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:
181
+ The async/browser worker reads only what you write in the marker. Self-sufficient means:
182
+
180
183
  - Spell out exactly what to do.
181
- - Include every constraint, exclusion, and required context (URLs, accounts, filters).
182
- - Reference any logged-in sessions the worker should use (e.g. "use the Rivoara TikTok account, already logged in").
183
- - Specify the expected output shape (fields, order, format).
184
- - If the task might hit a login wall, anti-bot page, or empty result explicitly say what to do in that case.
184
+ - Include every constraint, exclusion, URL, account, or filter.
185
+ - Reference any logged-in sessions the worker should use.
186
+ - Specify the expected output shape.
187
+ - Include bail conditions: "bail if same action fails 3 times", "bail if 3 consecutive empty/error responses", "bail if single tool call exceeds 5 min".
188
+ - Autonomy split: low-stakes picks (which hashtag first, which profile to open) — let the worker decide. Irreversible actions (DM send, post, purchase) — worker must STOP and report candidates, not act. The owner confirms in chat before a second task runs the action.
185
189
 
186
190
  Over-specify. A vague description produces a vague result.
187
191
 
192
+ ### Irreversible-action split: gather → confirm → act
193
+
194
+ For tasks with an irreversible write (DM, post, purchase), split into phases:
195
+
196
+ 1. **Gather** — `[ASYNC-BROWSER: find 5 HT user candidates with German content, active 30d. Output: list of handles, follower counts, one-line notes. Do NOT send anything.]`
197
+ 2. Worker returns candidates. You present to owner: "Found A, B, C, D, E. Which?"
198
+ 3. Owner replies: "B"
199
+ 4. **Act** — `[ASYNC-BROWSER: open DM to @B, type this template: ..., send. Confirm sent.]`
200
+
201
+ Two separate tasks. Owner is in the loop between them. Never skip the confirm step on irreversible writes.
202
+
188
203
  ### Avoiding duplicates
189
204
 
190
- 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."
205
+ If you see `[Async tasks in progress]` in your preamble, a worker is already running for this chat. Do NOT emit another marker for the same work. Reply naturally: "Still working on it, 4 minutes in."
191
206
 
192
207
  ## Sending files
193
208
 
@@ -207,8 +222,8 @@ Rules:
207
222
 
208
223
  ## Browser tools
209
224
 
210
- You have a Chrome browser via Playwright MCP: `browser_navigate`, `browser_take_screenshot`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_evaluate`, etc.
225
+ A shared Chrome runs on the server at `localhost:9222` with the owner's real sessions logged in (TikTok, Instagram, etc.). Playwright MCP connects to it. **You do not use the browser directly.** The browser track does — it's a parallel Claude worker with a persistent session dedicated to this Chrome.
211
226
 
212
- **Never use them inline.** All browser work goes through the async lane — see the ASYNC section above. No exceptions for "quick checks" or "just one URL". Delegate every time.
227
+ **Never call `browser_*` / `mcp__*playwright*` tools inline.** All browser work goes via `[ASYNC-BROWSER:...]`. See the two-track section above.
213
228
 
214
- To send a screenshot back from an async task: the async worker takes it with the browser tool (saving to `storage/temp/`), then includes `[IMAGE: /absolute/path.png]` in its result message.
229
+ To send a screenshot back: the browser worker takes it (saving to `storage/temp/`), then includes `[IMAGE: /absolute/path.png]` in its result message.
@@ -8,7 +8,13 @@
8
8
  // drops it entirely. That bug leaked two real markers into user-facing
9
9
  // replies today (DIGEST ~morning, ASYNC later). This parser closes that
10
10
  // whole class of failure.
11
- const KINDS = ['DIGEST', 'JOURNAL', 'JOURNAL-NEW', 'ASYNC'];
11
+ const KINDS = [
12
+ 'DIGEST',
13
+ 'JOURNAL',
14
+ 'JOURNAL-NEW',
15
+ 'ASYNC',
16
+ 'ASYNC-BROWSER',
17
+ ];
12
18
  // Walk backwards from the end of the string, tracking bracket depth, to find
13
19
  // the `[` that matches the final `]`. Returns the tag kind, its payload, and
14
20
  // everything before the tag. Returns null if the tail doesn't cleanly look
@@ -51,9 +57,11 @@ function peelTrailingTag(raw) {
51
57
  }
52
58
  // Peel trailing tags off the end of a reply. Supported:
53
59
  // [DIGEST: <reason>]
54
- // [JOURNAL:<slug> — <note>] (append entry)
55
- // [JOURNAL-NEW:<slug> — <purpose>] (create journal)
56
- // [ASYNC: <self-sufficient task description>]
60
+ // [JOURNAL:<slug> — <note>] (append entry)
61
+ // [JOURNAL-NEW:<slug> — <purpose>] (create journal)
62
+ // [ASYNC: <self-sufficient task description>] (general async lane)
63
+ // [ASYNC-BROWSER: <self-sufficient task description>] (browser lane,
64
+ // serialized, 1)
57
65
  // Multiple tags supported in any order at the tail. Tags must be the LAST
58
66
  // thing in the reply (after trimming trailing whitespace).
59
67
  //
@@ -65,6 +73,7 @@ export function extractFlags(reply) {
65
73
  const journals = [];
66
74
  const journalCreates = [];
67
75
  const asyncTasks = [];
76
+ const asyncBrowserTasks = [];
68
77
  while (true) {
69
78
  const peeled = peelTrailingTag(current);
70
79
  if (!peeled)
@@ -91,8 +100,20 @@ export function extractFlags(reply) {
91
100
  asyncTasks.unshift({ description: payload });
92
101
  }
93
102
  }
103
+ else if (kind === 'ASYNC-BROWSER') {
104
+ if (payload.length >= 8) {
105
+ asyncBrowserTasks.unshift({ description: payload });
106
+ }
107
+ }
94
108
  }
95
- return { clean: current, digest, journals, journalCreates, asyncTasks };
109
+ return {
110
+ clean: current,
111
+ digest,
112
+ journals,
113
+ journalCreates,
114
+ asyncTasks,
115
+ asyncBrowserTasks,
116
+ };
96
117
  }
97
118
  // Legacy helper kept so existing callers still compile.
98
119
  export function extractDigestFlag(reply) {
@@ -10,7 +10,7 @@ import { ensureScaffold } from './store.js';
10
10
  import { getRoleForContext } from '../wa/whitelist.js';
11
11
  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.`;
12
12
  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.`;
13
- const ASYNC_REMINDER = `BROWSER / SCRAPE / MULTI-STEP RESEARCH = always async. Never call browser tools (browser_navigate, browser_click, browser_take_screenshot, browser_evaluate, any mcp__*playwright*) inlinethey block this chat for minutes when pages hang. Instead: send a short ack ("On it, will report back.") AND append [ASYNC: <self-sufficient task description>] at the END of your reply. The async worker has full browser access and will report back here. Even for "just one URL" — always async.`;
13
+ const ASYNC_REMINDER = `TWO TRACKS run in parallel: you are the chat track, a separate browser track runs a persistent Claude session dedicated to the shared Chrome at localhost:9222. Never call browser tools (browser_*, mcp__*playwright*) yourselfdelegate via [ASYNC-BROWSER: <self-sufficient task description>] at the END of your reply, plus a short ack ("On it, will report back."). For non-browser long work (>30s, multi-step reasoning) use [ASYNC: ...]. Irreversible actions (DM send, post, purchase) split into gather→confirm→act phases never send on your own judgment.`;
14
14
  function buildCriticalSection(params) {
15
15
  const { senderNumber, roleName, role, userName } = params;
16
16
  const who = userName
@@ -59,9 +59,8 @@ export function buildMemoryPreamble(params) {
59
59
  ' [AUDIO: /absolute/path/to/file.mp3]\n' +
60
60
  ' [DOCUMENT: /absolute/path/to/file.pdf]\n' +
61
61
  'The tag will be stripped from the message. Use absolute paths only.\n\n' +
62
- 'Browser (Playwright MCP): a real Chrome browser is available for navigation, clicks, forms, screenshots, page content. ' +
63
- 'DO NOT call browser tools inline from this main chat lane they block the chat queue for minutes when pages stall (login walls, anti-bot, rate limits). ' +
64
- 'ALL browser work goes through the async lane. When a request needs browser: send a short ack AND append [ASYNC: <self-sufficient task description>] at the END of your reply. The async worker has full browser access; it will send the result back to this chat as a new message. Even a single URL check goes async. No exceptions.\n\n' +
62
+ 'Browser (Playwright MCP): a real Chrome at localhost:9222 with the owner\'s sessions logged in (TikTok, Instagram, etc.). DO NOT call browser tools yourself — they belong to the BROWSER TRACK, a parallel Claude worker with its own persistent session on that Chrome. ' +
63
+ 'When a request needs browser work: send a short ack AND append [ASYNC-BROWSER: <self-sufficient task description>] at the END of your reply. The browser worker picks it up, does the work in the logged-in Chrome, sends the result back to this chat as a new message. Single URL, quick check, full scrape — all go via [ASYNC-BROWSER:...]. No exceptions.\n\n' +
65
64
  'File storage: if you need to save any files (screenshots, research, notes), always save them to storage/temp/. Never save files to the project root.');
66
65
  // Critical section
67
66
  sections.push(buildCriticalSection({
@@ -1,5 +1,5 @@
1
- import { readFileSync } from 'fs';
2
- import { resolve } from 'path';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
3
  import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
4
4
  import { config } from '../config.js';
5
5
  import fastq from 'fastq';
@@ -275,3 +275,292 @@ function titleCaseSlug(slug) {
275
275
  function truncate(s, n) {
276
276
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
277
277
  }
278
+ // ============================================================================
279
+ // BROWSER LANE
280
+ // ============================================================================
281
+ // A second async lane dedicated to browser work. Key differences vs the
282
+ // general async lane above:
283
+ //
284
+ // - Concurrency is 1. Serialized against itself because (a) the shared
285
+ // Playwright MCP + Chrome is one physical resource, (b) the session below
286
+ // is persistent and --resume doesn't allow concurrent resumes.
287
+ // - One GLOBAL persistent session stored at storage/browser-session.json.
288
+ // First browser task bootstraps fresh (captures sessionId). Subsequent
289
+ // tasks spawn with --resume <sessionId>, so the browser Claude carries
290
+ // memory of prior tasks across runs.
291
+ // - Task description is added as a new user message to the persistent
292
+ // session. The worker sees the accumulated history automatically.
293
+ function browserSessionFilePath() {
294
+ return resolve(process.cwd(), config.memory.dir, 'browser-session.json');
295
+ }
296
+ function loadBrowserSession() {
297
+ const path = browserSessionFilePath();
298
+ if (!existsSync(path)) {
299
+ return { sessionId: null, createdAt: 0, lastUsedAt: 0, resumeCount: 0 };
300
+ }
301
+ try {
302
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
303
+ return {
304
+ sessionId: parsed.sessionId ?? null,
305
+ createdAt: parsed.createdAt ?? 0,
306
+ lastUsedAt: parsed.lastUsedAt ?? 0,
307
+ resumeCount: parsed.resumeCount ?? 0,
308
+ };
309
+ }
310
+ catch {
311
+ return { sessionId: null, createdAt: 0, lastUsedAt: 0, resumeCount: 0 };
312
+ }
313
+ }
314
+ function saveBrowserSession(state) {
315
+ const path = browserSessionFilePath();
316
+ mkdirSync(dirname(path), { recursive: true });
317
+ writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
318
+ }
319
+ // Reset the browser session. Callable from outside if the session gets
320
+ // corrupted or we want a fresh start. Not wired into any command yet.
321
+ export function resetBrowserSession() {
322
+ saveBrowserSession({
323
+ sessionId: null,
324
+ createdAt: 0,
325
+ lastUsedAt: 0,
326
+ resumeCount: 0,
327
+ });
328
+ logger.info('browser session reset');
329
+ }
330
+ const browserQueue = fastq.promise(async (task) => {
331
+ inProgress.set(task.id, task);
332
+ try {
333
+ await runBrowserTask(task);
334
+ }
335
+ catch (err) {
336
+ logger.error({ err, id: task.id, jid: task.jid }, 'browser task failed unexpectedly');
337
+ }
338
+ finally {
339
+ inProgress.delete(task.id);
340
+ }
341
+ }, 1);
342
+ export function enqueueBrowserTask(input) {
343
+ const task = {
344
+ ...input,
345
+ id: `browser-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
346
+ startedAt: Math.floor(Date.now() / 1000),
347
+ };
348
+ logger.info({
349
+ id: task.id,
350
+ jid: task.jid,
351
+ description: task.description.slice(0, 200),
352
+ }, 'browser task enqueued');
353
+ browserQueue.push(task).catch((err) => logger.error({ err, id: task.id }, 'browser queue push failed'));
354
+ return task;
355
+ }
356
+ function buildBrowserPrompt(task, isResume) {
357
+ // Framing tuned for the dedicated browser worker.
358
+ const lines = [
359
+ isResume
360
+ ? `You are the BROWSER WORKER. Another task just came in. You already have memory of prior browser tasks in this session — act on it accordingly. Use the shared Chrome at localhost:9222 via Playwright MCP (already logged into the owner's sessions like TikTok, Instagram, etc. — do NOT log out, do NOT start a new browser instance).`
361
+ : `You are the BROWSER WORKER. You run in a persistent session dedicated to browser tasks for the owner. The chat already got its ack; your output IS the follow-up chat reply the owner is waiting for. Use the shared Chrome at localhost:9222 via Playwright MCP (already authenticated with the owner's sessions — TikTok, Instagram, etc. — do NOT log out, do NOT launch a new browser).`,
362
+ ``,
363
+ `TASK:`,
364
+ task.description,
365
+ ``,
366
+ `ORIGINAL USER MESSAGE (for reference):`,
367
+ task.originatingMessage,
368
+ ``,
369
+ `Sender: ${task.senderName ?? task.senderNumber}`,
370
+ ``,
371
+ `HOW TO OUTPUT:`,
372
+ `- Write the full answer as a natural chat reply. Same voice as the main chat Claude, just delayed.`,
373
+ `- Open with a short "about the X you asked about..." reference — the owner may have asked for several things.`,
374
+ `- Concrete findings only. Numbers, names, dates. If you found 10 creators, list them.`,
375
+ `- Failure mode: page hung, login wall, bot-detection, empty feed — say so briefly. Do NOT fabricate.`,
376
+ ``,
377
+ `BAIL CONDITIONS (stop and report, don't burn the clock):`,
378
+ `- Same tool call with same args retried 3 times → stuck, bail.`,
379
+ `- 3 consecutive empty/error responses from the site → site is throttling, bail.`,
380
+ `- Any single tool call running past 5 min → bail.`,
381
+ `- Autonomy: pick and proceed on low-stakes choices (which hashtag first, which profile to open). For IRREVERSIBLE writes (DM send, post, purchase), do NOT act — stop and report candidates so the owner can confirm in chat.`,
382
+ ``,
383
+ `OPTIONAL MARKERS (at the END of your output):`,
384
+ `- [JOURNAL:<slug> — <one-line finding>] per finding that belongs in an active journal.`,
385
+ `- [JOURNAL-NEW:<slug> — <purpose>] if a clearly-recurring tracking surface doesn't have a journal yet.`,
386
+ `- [DIGEST: <reason>] if a durable fact about the owner/chat came up.`,
387
+ ``,
388
+ `CONSTRAINTS:`,
389
+ `- Do NOT emit [ASYNC:...] or [ASYNC-BROWSER:...]. No recursion.`,
390
+ `- Markers are bonus persistence, not a substitute for the reply.`,
391
+ `- Stay fully in character (personality).`,
392
+ ``,
393
+ `Do the work. Write the reply. Markers optional at the end.`,
394
+ ];
395
+ return lines.join('\n');
396
+ }
397
+ function buildBrowserArgs(task, sessionId) {
398
+ const args = [
399
+ '-p',
400
+ '--output-format',
401
+ 'json',
402
+ '--model',
403
+ config.claude.model,
404
+ '--permission-mode',
405
+ 'acceptEdits',
406
+ ];
407
+ if (sessionId) {
408
+ // Resume — system prompt and memory-dirs are already baked into session
409
+ args.push('--resume', sessionId);
410
+ }
411
+ else {
412
+ // First call — bootstrap the persistent session
413
+ args.push('--append-system-prompt', systemPrompt());
414
+ for (const dir of config.claude.addDirs) {
415
+ args.push('--add-dir', resolve(process.cwd(), dir));
416
+ }
417
+ }
418
+ // Memory + media dirs re-added each call (harmless if already baked; needed
419
+ // on fresh bootstrap; lets the browser worker Read updated memory files
420
+ // between turns).
421
+ args.push('--add-dir', resolve(process.cwd(), config.memory.dir));
422
+ args.push('--add-dir', resolve(process.cwd(), config.storage.mediaDir));
423
+ if (task.allowedTools &&
424
+ task.allowedTools !== 'all' &&
425
+ task.allowedTools.length > 0) {
426
+ args.push('--allowedTools', task.allowedTools.join(','));
427
+ }
428
+ return args;
429
+ }
430
+ async function runBrowserTask(task) {
431
+ const session = loadBrowserSession();
432
+ const isResume = !!session.sessionId;
433
+ const prompt = buildBrowserPrompt(task, isResume);
434
+ const args = buildBrowserArgs(task, session.sessionId);
435
+ const startedAtMs = Date.now();
436
+ const elapsedLog = () => `${Math.round((Date.now() - task.startedAt * 1000) / 1000)}s`;
437
+ let stdout;
438
+ let durationMs;
439
+ try {
440
+ const result = await runClaude({
441
+ args,
442
+ input: prompt,
443
+ timeoutMs: TIMEOUT_MS.async,
444
+ caller: 'browser-task',
445
+ });
446
+ stdout = result.stdout;
447
+ durationMs = result.durationMs;
448
+ }
449
+ catch (err) {
450
+ logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'browser task claude call failed');
451
+ await initiate({
452
+ jid: task.jid,
453
+ text: `Heads up: the browser task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
454
+ });
455
+ return;
456
+ }
457
+ let parsed;
458
+ try {
459
+ parsed = JSON.parse(stdout);
460
+ }
461
+ catch (err) {
462
+ logger.error({ err, id: task.id }, 'browser task: failed to parse claude output');
463
+ await initiate({
464
+ jid: task.jid,
465
+ text: `Heads up: the browser task "${truncate(task.description, 80)}" returned an unparseable response.`,
466
+ });
467
+ return;
468
+ }
469
+ if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
470
+ logger.error({ parsed, id: task.id }, 'browser task bad output');
471
+ await initiate({
472
+ jid: task.jid,
473
+ text: `Heads up: the browser task "${truncate(task.description, 80)}" returned an error.`,
474
+ });
475
+ return;
476
+ }
477
+ // Persist the session id. On first call Claude returns the new sessionId;
478
+ // on resume it may return the same or a rotated one.
479
+ const returnedSessionId = parsed.session_id ?? null;
480
+ if (returnedSessionId) {
481
+ const now = Math.floor(Date.now() / 1000);
482
+ saveBrowserSession({
483
+ sessionId: returnedSessionId,
484
+ createdAt: session.createdAt || now,
485
+ lastUsedAt: now,
486
+ resumeCount: (session.resumeCount ?? 0) + (isResume ? 1 : 0),
487
+ });
488
+ }
489
+ void logPrompt({
490
+ ts: Math.floor(startedAtMs / 1000),
491
+ caller: 'browser-task',
492
+ args,
493
+ input: prompt,
494
+ output: parsed.result,
495
+ sessionId: returnedSessionId ?? undefined,
496
+ durationMs,
497
+ });
498
+ // Route markers the same way the general async lane does.
499
+ const { extractFlags } = await import('../memory/digest-flag.js');
500
+ const { clean, digest, journals, journalCreates } = extractFlags(parsed.result);
501
+ const { appendEntry, createJournal, getJournal, isValidSlug } = await import('../memory/journals.js');
502
+ for (const op of journalCreates) {
503
+ if (!isValidSlug(op.slug))
504
+ continue;
505
+ if (getJournal(op.slug))
506
+ continue;
507
+ try {
508
+ createJournal({
509
+ slug: op.slug,
510
+ name: titleCaseSlug(op.slug),
511
+ purpose: op.purpose,
512
+ });
513
+ logger.info({ slug: op.slug, id: task.id }, 'journal created via browser task marker');
514
+ }
515
+ catch (err) {
516
+ logger.error({ err, op, id: task.id }, 'browser JOURNAL-NEW failed');
517
+ }
518
+ }
519
+ let appendedCount = 0;
520
+ for (const j of journals) {
521
+ const ok = appendEntry(j.slug, {
522
+ source: 'async',
523
+ jid: task.jid,
524
+ senderNumber: task.senderNumber,
525
+ note: j.note,
526
+ });
527
+ if (ok)
528
+ appendedCount++;
529
+ }
530
+ if (digest) {
531
+ const { scheduleDigest } = await import('../memory/scheduler.js');
532
+ scheduleDigest({
533
+ jid: task.jid,
534
+ number: task.senderNumber,
535
+ reason: digest,
536
+ });
537
+ }
538
+ const chatText = clean.trim();
539
+ if (chatText.length > 0) {
540
+ await initiate({ jid: task.jid, text: chatText });
541
+ }
542
+ else if (appendedCount > 0 ||
543
+ journalCreates.length > 0 ||
544
+ digest !== null) {
545
+ const bits = [];
546
+ if (appendedCount > 0) {
547
+ bits.push(`${appendedCount} journal ${appendedCount === 1 ? 'entry' : 'entries'}`);
548
+ }
549
+ if (journalCreates.length > 0) {
550
+ bits.push(`${journalCreates.length} journal${journalCreates.length === 1 ? '' : 's'} created`);
551
+ }
552
+ if (digest)
553
+ bits.push('digest scheduled');
554
+ await initiate({ jid: task.jid, text: `Done. ${bits.join(', ')}.` });
555
+ }
556
+ logger.info({
557
+ id: task.id,
558
+ jid: task.jid,
559
+ elapsed: elapsedLog(),
560
+ isResume,
561
+ appended: appendedCount,
562
+ createdJournals: journalCreates.length,
563
+ digestFired: !!digest,
564
+ chatSent: chatText.length,
565
+ }, 'browser task completed');
566
+ }
@@ -5,7 +5,7 @@ import { logger } from '../logger.js';
5
5
  import { extractFlags } from '../memory/digest-flag.js';
6
6
  import { appendEntry, createJournal, getJournal, isValidSlug, } from '../memory/journals.js';
7
7
  import { scheduleDigest } from '../memory/scheduler.js';
8
- import { enqueueAsyncTask } from './async-tasks.js';
8
+ import { enqueueAsyncTask, enqueueBrowserTask } from './async-tasks.js';
9
9
  function isStaleSessionError(err) {
10
10
  return (err instanceof Error &&
11
11
  err.message.includes('No conversation found'));
@@ -31,7 +31,7 @@ async function callClaude(job) {
31
31
  totalContextTokens,
32
32
  updatedAt: Math.floor(Date.now() / 1000),
33
33
  });
34
- const { clean, digest, journals, journalCreates, asyncTasks } = extractFlags(reply);
34
+ const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, } = extractFlags(reply);
35
35
  if (digest) {
36
36
  logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
37
37
  scheduleDigest({
@@ -74,10 +74,11 @@ async function callClaude(job) {
74
74
  logger.warn({ slug: j.slug, jid: job.jid }, 'JOURNAL flag pointed at unknown slug, dropped');
75
75
  }
76
76
  }
77
- // Async tasks: Claude delegated long work (browser scrapes, multi-step
78
- // research, etc.) to the background lane. The clean reply above is the
79
- // user-facing ack and will be sent normally. The async tasks run stateless
80
- // in their own queue and report back via initiate() when done.
77
+ // Async tasks: Claude delegated to background workers. Chat reply above
78
+ // is the user-facing ack. Two lanes:
79
+ // [ASYNC:...] general lane, stateless, concurrency 3, non-browser work
80
+ // [ASYNC-BROWSER:...] browser lane, persistent session, concurrency 1
81
+ // Both report back via initiate() when done.
81
82
  for (const t of asyncTasks) {
82
83
  enqueueAsyncTask({
83
84
  jid: job.jid,
@@ -87,6 +88,15 @@ async function callClaude(job) {
87
88
  allowedTools: job.allowedTools ?? 'all',
88
89
  });
89
90
  }
91
+ for (const t of asyncBrowserTasks) {
92
+ enqueueBrowserTask({
93
+ jid: job.jid,
94
+ senderNumber: job.senderNumber,
95
+ description: t.description,
96
+ originatingMessage: job.text,
97
+ allowedTools: job.allowedTools ?? 'all',
98
+ });
99
+ }
90
100
  return {
91
101
  reply: clean,
92
102
  stats: {
@@ -99,7 +109,7 @@ async function callClaude(job) {
99
109
  fresh: wasFresh,
100
110
  hasDigest: digest !== null,
101
111
  journalSlugs: journals.map((j) => j.slug),
102
- asyncCount: asyncTasks.length,
112
+ asyncCount: asyncTasks.length + asyncBrowserTasks.length,
103
113
  },
104
114
  };
105
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.7.5",
3
+ "version": "0.8.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",