@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.
- package/config/memory-instructions.md +48 -33
- package/dist/memory/digest-flag.js +26 -5
- package/dist/memory/preamble.js +3 -4
- package/dist/queue/async-tasks.js +291 -2
- package/dist/queue/worker.js +17 -7
- package/package.json +1 -1
|
@@ -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
|
-
##
|
|
134
|
+
## Background work: two parallel tracks
|
|
135
135
|
|
|
136
|
-
**
|
|
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
|
-
|
|
138
|
+
Your job: decide what YOU handle vs what you hand off to the browser track.
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
### Delegate to the browser track
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
**ANY browser tool use goes to the browser track. No exceptions. Ever.**
|
|
143
143
|
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
158
|
+
`[ASYNC: ...]` (no `-BROWSER`) for non-browser background tasks that would take more than ~30 seconds:
|
|
163
159
|
|
|
164
|
-
|
|
165
|
-
-
|
|
166
|
-
- Anything
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
- Single quick non-browser tool calls (
|
|
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
|
|
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
|
|
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,
|
|
182
|
-
- Reference any logged-in sessions the worker should use
|
|
183
|
-
- Specify the expected output shape
|
|
184
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = [
|
|
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>]
|
|
55
|
-
// [JOURNAL-NEW:<slug> — <purpose>]
|
|
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 {
|
|
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) {
|
package/dist/memory/preamble.js
CHANGED
|
@@ -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 = `
|
|
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*) yourself — delegate 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
|
|
63
|
-
'
|
|
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
|
+
}
|
package/dist/queue/worker.js
CHANGED
|
@@ -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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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
|
}
|