@c4t4/heyamigo 0.7.5 → 0.8.1
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/ai/claude.js +17 -14
- package/dist/ai/spawn.js +64 -1
- package/dist/memory/compressed.js +10 -11
- package/dist/memory/digest-flag.js +26 -5
- package/dist/memory/digest.js +10 -11
- package/dist/memory/journal-nudger.js +10 -11
- package/dist/memory/journal-observer.js +10 -11
- package/dist/memory/preamble.js +3 -4
- package/dist/promptlog.js +26 -1
- package/dist/queue/async-tasks.js +302 -13
- 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.
|
package/dist/ai/claude.js
CHANGED
|
@@ -3,7 +3,7 @@ import { resolve } from 'path';
|
|
|
3
3
|
import { config } from '../config.js';
|
|
4
4
|
import { logger } from '../logger.js';
|
|
5
5
|
import { logPrompt } from '../promptlog.js';
|
|
6
|
-
import { runClaude, TIMEOUT_MS } from './spawn.js';
|
|
6
|
+
import { parseStreamJson, runClaude, TIMEOUT_MS } from './spawn.js';
|
|
7
7
|
let cachedSystemPrompt = null;
|
|
8
8
|
function systemPrompt() {
|
|
9
9
|
if (cachedSystemPrompt !== null)
|
|
@@ -25,10 +25,14 @@ export function reloadSystemPrompt() {
|
|
|
25
25
|
cachedSystemPrompt = null;
|
|
26
26
|
}
|
|
27
27
|
function buildArgs(params) {
|
|
28
|
+
// stream-json gives per-event visibility into the agent loop (system init,
|
|
29
|
+
// assistant messages, tool_use, tool_result, final result). We parse the
|
|
30
|
+
// final 'result' event for the return shape, and log event types for
|
|
31
|
+
// diagnostic purposes.
|
|
28
32
|
const args = [
|
|
29
33
|
'-p',
|
|
30
34
|
'--output-format',
|
|
31
|
-
|
|
35
|
+
'stream-json',
|
|
32
36
|
'--model',
|
|
33
37
|
config.claude.model,
|
|
34
38
|
'--permission-mode',
|
|
@@ -57,35 +61,32 @@ function buildArgs(params) {
|
|
|
57
61
|
export async function askClaude(params) {
|
|
58
62
|
const args = buildArgs(params);
|
|
59
63
|
logger.debug({ resume: !!params.sessionId, inputChars: params.input.length }, 'spawning claude');
|
|
60
|
-
const { stdout, durationMs } = await runClaude({
|
|
64
|
+
const { stdout, stderr, durationMs } = await runClaude({
|
|
61
65
|
args,
|
|
62
66
|
input: params.input,
|
|
63
67
|
timeoutMs: TIMEOUT_MS.main,
|
|
64
68
|
caller: 'worker',
|
|
65
69
|
});
|
|
66
70
|
const startedAt = Date.now() - durationMs;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
catch (err) {
|
|
72
|
-
throw new Error(`failed to parse claude output: ${err.message}\nstdout: ${stdout.slice(0, 500)}`);
|
|
71
|
+
const parsed = parseStreamJson(stdout);
|
|
72
|
+
if (!parsed) {
|
|
73
|
+
throw new Error(`claude stream-json produced no result event; stdout: ${stdout.slice(0, 500)}`);
|
|
73
74
|
}
|
|
74
|
-
if (parsed.
|
|
75
|
-
throw new Error(`claude returned error (subtype=${parsed.subtype}): ${parsed.result
|
|
75
|
+
if (parsed.isError || parsed.subtype !== 'success') {
|
|
76
|
+
throw new Error(`claude returned error (subtype=${parsed.subtype}): ${parsed.result}`);
|
|
76
77
|
}
|
|
77
|
-
if (!parsed.result || !parsed.
|
|
78
|
+
if (!parsed.result || !parsed.sessionId) {
|
|
78
79
|
throw new Error(`claude output missing result or session_id: ${stdout.slice(0, 200)}`);
|
|
79
80
|
}
|
|
80
81
|
const result = {
|
|
81
82
|
reply: parsed.result,
|
|
82
|
-
sessionId: parsed.
|
|
83
|
+
sessionId: parsed.sessionId,
|
|
83
84
|
usage: {
|
|
84
85
|
inputTokens: parsed.usage?.input_tokens ?? 0,
|
|
85
86
|
cacheReadTokens: parsed.usage?.cache_read_input_tokens ?? 0,
|
|
86
87
|
cacheCreationTokens: parsed.usage?.cache_creation_input_tokens ?? 0,
|
|
87
88
|
outputTokens: parsed.usage?.output_tokens ?? 0,
|
|
88
|
-
numTurns: parsed.
|
|
89
|
+
numTurns: parsed.numTurns ?? 0,
|
|
89
90
|
},
|
|
90
91
|
};
|
|
91
92
|
void logPrompt({
|
|
@@ -97,6 +98,8 @@ export async function askClaude(params) {
|
|
|
97
98
|
sessionId: result.sessionId,
|
|
98
99
|
usage: result.usage,
|
|
99
100
|
durationMs,
|
|
101
|
+
stderr,
|
|
102
|
+
eventTypes: parsed.eventTypes,
|
|
100
103
|
});
|
|
101
104
|
return result;
|
|
102
105
|
}
|
package/dist/ai/spawn.js
CHANGED
|
@@ -21,6 +21,60 @@ export class ClaudeSpawnError extends Error {
|
|
|
21
21
|
this.name = 'ClaudeSpawnError';
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
// Parse Claude CLI's --output-format stream-json output. Each line is a JSON
|
|
25
|
+
// event; the final event with type === 'result' carries the completion
|
|
26
|
+
// summary (same shape as the old single-json output format). Returns null if
|
|
27
|
+
// no result event is found — caller should treat that as an error.
|
|
28
|
+
export function parseStreamJson(stdout) {
|
|
29
|
+
const events = [];
|
|
30
|
+
const eventTypes = [];
|
|
31
|
+
const lines = stdout.split(/\r?\n/);
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
if (!trimmed)
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(trimmed);
|
|
38
|
+
events.push(parsed);
|
|
39
|
+
if (typeof parsed.type === 'string')
|
|
40
|
+
eventTypes.push(parsed.type);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Ignore malformed lines — Claude CLI occasionally emits preamble or
|
|
44
|
+
// debug lines that aren't JSON; the structured events we need are
|
|
45
|
+
// always well-formed.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Find the final result event. Walk from end to handle any stray events
|
|
49
|
+
// after 'result' (shouldn't happen but be defensive).
|
|
50
|
+
let resultEvent = null;
|
|
51
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
52
|
+
if (events[i].type === 'result') {
|
|
53
|
+
resultEvent = events[i];
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!resultEvent)
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
result: typeof resultEvent.result === 'string' ? resultEvent.result : '',
|
|
61
|
+
sessionId: typeof resultEvent.session_id === 'string'
|
|
62
|
+
? resultEvent.session_id
|
|
63
|
+
: null,
|
|
64
|
+
usage: resultEvent.usage && typeof resultEvent.usage === 'object'
|
|
65
|
+
? resultEvent.usage
|
|
66
|
+
: undefined,
|
|
67
|
+
isError: !!resultEvent.is_error,
|
|
68
|
+
subtype: typeof resultEvent.subtype === 'string'
|
|
69
|
+
? resultEvent.subtype
|
|
70
|
+
: undefined,
|
|
71
|
+
numTurns: typeof resultEvent.num_turns === 'number'
|
|
72
|
+
? resultEvent.num_turns
|
|
73
|
+
: undefined,
|
|
74
|
+
eventTypes,
|
|
75
|
+
events,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
24
78
|
// Kill the process group of a detached child. Playwright MCP and any Chromium
|
|
25
79
|
// children sit under the claude subprocess; without process-group kill they
|
|
26
80
|
// linger after we SIGTERM the parent and accumulate on the host.
|
|
@@ -54,6 +108,15 @@ export async function runClaude(opts) {
|
|
|
54
108
|
// detached:true puts the child in its own process group, so killGroup
|
|
55
109
|
// can SIGTERM the whole tree (Playwright MCP, Chromium, etc.) at once.
|
|
56
110
|
detached: true,
|
|
111
|
+
// ANTHROPIC_LOG=debug surfaces the SDK's HTTP layer to stderr:
|
|
112
|
+
// request URLs, status codes, retries, rate-limit notices. We
|
|
113
|
+
// capture stderr and put a truncated copy into the promptlog so
|
|
114
|
+
// we can diagnose API hangs/rate-limits post-mortem instead of
|
|
115
|
+
// staring at "Claude subprocess is idle, why?".
|
|
116
|
+
env: {
|
|
117
|
+
...process.env,
|
|
118
|
+
ANTHROPIC_LOG: process.env.ANTHROPIC_LOG ?? 'debug',
|
|
119
|
+
},
|
|
57
120
|
});
|
|
58
121
|
let stdout = '';
|
|
59
122
|
let stderr = '';
|
|
@@ -106,7 +169,7 @@ export async function runClaude(opts) {
|
|
|
106
169
|
logFail(`exit ${code}: ${stderr.slice(0, 500)}`);
|
|
107
170
|
return rejectPromise(new ClaudeSpawnError(caller, `claude exited with code ${code}: ${stderr.slice(0, 500)}`));
|
|
108
171
|
}
|
|
109
|
-
resolvePromise({ stdout, durationMs });
|
|
172
|
+
resolvePromise({ stdout, stderr, durationMs });
|
|
110
173
|
});
|
|
111
174
|
child.stdin.write(input);
|
|
112
175
|
child.stdin.end();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, writeFileSync, } from 'fs';
|
|
2
2
|
import { dirname, resolve } from 'path';
|
|
3
3
|
import { mkdirSync } from 'fs';
|
|
4
|
-
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
4
|
+
import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
5
5
|
import { config } from '../config.js';
|
|
6
6
|
import { logger } from '../logger.js';
|
|
7
7
|
import { logPrompt } from '../promptlog.js';
|
|
@@ -224,28 +224,25 @@ async function spawnGenerator(prompt) {
|
|
|
224
224
|
const args = [
|
|
225
225
|
'-p',
|
|
226
226
|
'--output-format',
|
|
227
|
-
'json',
|
|
227
|
+
'stream-json',
|
|
228
228
|
'--model',
|
|
229
229
|
config.claude.model,
|
|
230
230
|
'--permission-mode',
|
|
231
231
|
'acceptEdits',
|
|
232
232
|
];
|
|
233
|
-
const { stdout, durationMs } = await runClaude({
|
|
233
|
+
const { stdout, stderr, durationMs } = await runClaude({
|
|
234
234
|
args,
|
|
235
235
|
input: prompt,
|
|
236
236
|
timeoutMs: TIMEOUT_MS.background,
|
|
237
237
|
caller: 'compressed',
|
|
238
238
|
});
|
|
239
239
|
const startedAt = Date.now() - durationMs;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
catch (err) {
|
|
245
|
-
throw new Error(`compressed parse failed: ${err.message}`);
|
|
240
|
+
const parsed = parseStreamJson(stdout);
|
|
241
|
+
if (!parsed) {
|
|
242
|
+
throw new Error(`compressed stream-json produced no result event: ${stdout.slice(0, 200)}`);
|
|
246
243
|
}
|
|
247
|
-
if (parsed.
|
|
248
|
-
throw new Error(`compressed bad output: ${parsed.result
|
|
244
|
+
if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
|
|
245
|
+
throw new Error(`compressed bad output: ${parsed.result || stdout.slice(0, 200)}`);
|
|
249
246
|
}
|
|
250
247
|
const output = parsed.result.trim();
|
|
251
248
|
void logPrompt({
|
|
@@ -255,6 +252,8 @@ async function spawnGenerator(prompt) {
|
|
|
255
252
|
input: prompt,
|
|
256
253
|
output,
|
|
257
254
|
durationMs,
|
|
255
|
+
stderr,
|
|
256
|
+
eventTypes: parsed.eventTypes,
|
|
258
257
|
});
|
|
259
258
|
return output;
|
|
260
259
|
}
|
|
@@ -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/digest.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
1
|
+
import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
2
2
|
import { config } from '../config.js';
|
|
3
3
|
import { logger } from '../logger.js';
|
|
4
4
|
import { logPrompt } from '../promptlog.js';
|
|
@@ -13,28 +13,25 @@ async function spawnDigester(prompt) {
|
|
|
13
13
|
const args = [
|
|
14
14
|
'-p',
|
|
15
15
|
'--output-format',
|
|
16
|
-
'json',
|
|
16
|
+
'stream-json',
|
|
17
17
|
'--model',
|
|
18
18
|
config.claude.model,
|
|
19
19
|
'--permission-mode',
|
|
20
20
|
'acceptEdits',
|
|
21
21
|
];
|
|
22
|
-
const { stdout, durationMs } = await runClaude({
|
|
22
|
+
const { stdout, stderr, durationMs } = await runClaude({
|
|
23
23
|
args,
|
|
24
24
|
input: prompt,
|
|
25
25
|
timeoutMs: TIMEOUT_MS.background,
|
|
26
26
|
caller: 'digester',
|
|
27
27
|
});
|
|
28
28
|
const startedAt = Date.now() - durationMs;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
catch (err) {
|
|
34
|
-
throw new Error(`digester parse failed: ${err.message}`);
|
|
29
|
+
const parsed = parseStreamJson(stdout);
|
|
30
|
+
if (!parsed) {
|
|
31
|
+
throw new Error(`digester stream-json produced no result event: ${stdout.slice(0, 200)}`);
|
|
35
32
|
}
|
|
36
|
-
if (parsed.
|
|
37
|
-
throw new Error(`digester bad output: ${parsed.result
|
|
33
|
+
if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
|
|
34
|
+
throw new Error(`digester bad output: ${parsed.result || stdout.slice(0, 200)}`);
|
|
38
35
|
}
|
|
39
36
|
const output = parsed.result.trim();
|
|
40
37
|
void logPrompt({
|
|
@@ -44,6 +41,8 @@ async function spawnDigester(prompt) {
|
|
|
44
41
|
input: prompt,
|
|
45
42
|
output,
|
|
46
43
|
durationMs,
|
|
44
|
+
stderr,
|
|
45
|
+
eventTypes: parsed.eventTypes,
|
|
47
46
|
});
|
|
48
47
|
return output;
|
|
49
48
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
1
|
+
import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
2
2
|
import { config } from '../config.js';
|
|
3
3
|
import { initiate } from '../gateway/outgoing.js';
|
|
4
4
|
import { logger } from '../logger.js';
|
|
@@ -18,28 +18,25 @@ async function spawnComposer(prompt) {
|
|
|
18
18
|
const args = [
|
|
19
19
|
'-p',
|
|
20
20
|
'--output-format',
|
|
21
|
-
'json',
|
|
21
|
+
'stream-json',
|
|
22
22
|
'--model',
|
|
23
23
|
config.claude.model,
|
|
24
24
|
'--permission-mode',
|
|
25
25
|
'acceptEdits',
|
|
26
26
|
];
|
|
27
|
-
const { stdout, durationMs } = await runClaude({
|
|
27
|
+
const { stdout, stderr, durationMs } = await runClaude({
|
|
28
28
|
args,
|
|
29
29
|
input: prompt,
|
|
30
30
|
timeoutMs: TIMEOUT_MS.background,
|
|
31
31
|
caller: 'journal-nudger',
|
|
32
32
|
});
|
|
33
33
|
const startedAt = Date.now() - durationMs;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
catch (err) {
|
|
39
|
-
throw new Error(`nudger parse failed: ${err.message}`);
|
|
34
|
+
const parsed = parseStreamJson(stdout);
|
|
35
|
+
if (!parsed) {
|
|
36
|
+
throw new Error(`nudger stream-json produced no result event: ${stdout.slice(0, 200)}`);
|
|
40
37
|
}
|
|
41
|
-
if (parsed.
|
|
42
|
-
throw new Error(`nudger bad output: ${parsed.result
|
|
38
|
+
if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
|
|
39
|
+
throw new Error(`nudger bad output: ${parsed.result || stdout.slice(0, 200)}`);
|
|
43
40
|
}
|
|
44
41
|
const output = parsed.result.trim();
|
|
45
42
|
void logPrompt({
|
|
@@ -49,6 +46,8 @@ async function spawnComposer(prompt) {
|
|
|
49
46
|
input: prompt,
|
|
50
47
|
output,
|
|
51
48
|
durationMs,
|
|
49
|
+
stderr,
|
|
50
|
+
eventTypes: parsed.eventTypes,
|
|
52
51
|
});
|
|
53
52
|
return output;
|
|
54
53
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
1
|
+
import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
2
2
|
import { config } from '../config.js';
|
|
3
3
|
import { logger } from '../logger.js';
|
|
4
4
|
import { logPrompt } from '../promptlog.js';
|
|
@@ -14,28 +14,25 @@ async function spawnObserver(prompt) {
|
|
|
14
14
|
const args = [
|
|
15
15
|
'-p',
|
|
16
16
|
'--output-format',
|
|
17
|
-
'json',
|
|
17
|
+
'stream-json',
|
|
18
18
|
'--model',
|
|
19
19
|
config.claude.model,
|
|
20
20
|
'--permission-mode',
|
|
21
21
|
'acceptEdits',
|
|
22
22
|
];
|
|
23
|
-
const { stdout, durationMs } = await runClaude({
|
|
23
|
+
const { stdout, stderr, durationMs } = await runClaude({
|
|
24
24
|
args,
|
|
25
25
|
input: prompt,
|
|
26
26
|
timeoutMs: TIMEOUT_MS.background,
|
|
27
27
|
caller: 'journal-observer',
|
|
28
28
|
});
|
|
29
29
|
const startedAt = Date.now() - durationMs;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
catch (err) {
|
|
35
|
-
throw new Error(`journal observer parse failed: ${err.message}`);
|
|
30
|
+
const parsed = parseStreamJson(stdout);
|
|
31
|
+
if (!parsed) {
|
|
32
|
+
throw new Error(`journal observer stream-json produced no result event: ${stdout.slice(0, 200)}`);
|
|
36
33
|
}
|
|
37
|
-
if (parsed.
|
|
38
|
-
throw new Error(`journal observer bad output: ${parsed.result
|
|
34
|
+
if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
|
|
35
|
+
throw new Error(`journal observer bad output: ${parsed.result || stdout.slice(0, 200)}`);
|
|
39
36
|
}
|
|
40
37
|
const output = parsed.result.trim();
|
|
41
38
|
void logPrompt({
|
|
@@ -45,6 +42,8 @@ async function spawnObserver(prompt) {
|
|
|
45
42
|
input: prompt,
|
|
46
43
|
output,
|
|
47
44
|
durationMs,
|
|
45
|
+
stderr,
|
|
46
|
+
eventTypes: parsed.eventTypes,
|
|
48
47
|
});
|
|
49
48
|
return output;
|
|
50
49
|
}
|
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({
|
package/dist/promptlog.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { appendFile, mkdir, readdir, unlink } from 'fs/promises';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
3
|
import { config } from './config.js';
|
|
4
|
+
// Hard caps on fields that can grow unbounded. Prevents promptlog entries
|
|
5
|
+
// from exploding when a run produces huge stdout/stderr. prunePrompts
|
|
6
|
+
// still handles multi-day retention separately.
|
|
7
|
+
const STDOUT_MAX_BYTES = 100_000;
|
|
8
|
+
const STDERR_MAX_BYTES = 50_000;
|
|
9
|
+
const INPUT_MAX_BYTES = 200_000;
|
|
10
|
+
function truncateWithMarker(s, maxBytes) {
|
|
11
|
+
if (!s)
|
|
12
|
+
return s;
|
|
13
|
+
// Rough byte size via length — fine for mostly-ASCII prompt payloads.
|
|
14
|
+
if (s.length <= maxBytes)
|
|
15
|
+
return s;
|
|
16
|
+
const extra = s.length - maxBytes;
|
|
17
|
+
return s.slice(0, maxBytes) + `\n… [truncated ${extra} bytes]`;
|
|
18
|
+
}
|
|
4
19
|
let dirReady = false;
|
|
5
20
|
function promptsDir() {
|
|
6
21
|
return resolve(process.cwd(), 'storage/prompts');
|
|
@@ -18,7 +33,17 @@ function logFilePath() {
|
|
|
18
33
|
export async function logPrompt(entry) {
|
|
19
34
|
try {
|
|
20
35
|
await ensureDir();
|
|
21
|
-
|
|
36
|
+
const capped = {
|
|
37
|
+
...entry,
|
|
38
|
+
input: truncateWithMarker(entry.input, INPUT_MAX_BYTES),
|
|
39
|
+
};
|
|
40
|
+
if (entry.output !== undefined) {
|
|
41
|
+
capped.output = truncateWithMarker(entry.output, STDOUT_MAX_BYTES);
|
|
42
|
+
}
|
|
43
|
+
if (entry.stderr !== undefined) {
|
|
44
|
+
capped.stderr = truncateWithMarker(entry.stderr, STDERR_MAX_BYTES);
|
|
45
|
+
}
|
|
46
|
+
await appendFile(logFilePath(), JSON.stringify(capped) + '\n', 'utf-8');
|
|
22
47
|
}
|
|
23
48
|
catch {
|
|
24
49
|
// logging must never break the main flow
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from 'fs';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
|
-
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, resolve } from 'path';
|
|
3
|
+
import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
4
4
|
import { config } from '../config.js';
|
|
5
5
|
import fastq from 'fastq';
|
|
6
6
|
import { initiate } from '../gateway/outgoing.js';
|
|
@@ -113,7 +113,7 @@ function buildArgs(task) {
|
|
|
113
113
|
const args = [
|
|
114
114
|
'-p',
|
|
115
115
|
'--output-format',
|
|
116
|
-
'json',
|
|
116
|
+
'stream-json',
|
|
117
117
|
'--model',
|
|
118
118
|
config.claude.model,
|
|
119
119
|
'--permission-mode',
|
|
@@ -135,22 +135,19 @@ function buildArgs(task) {
|
|
|
135
135
|
}
|
|
136
136
|
async function spawnClaudeForTask(task, prompt) {
|
|
137
137
|
const args = buildArgs(task);
|
|
138
|
-
const { stdout, durationMs } = await runClaude({
|
|
138
|
+
const { stdout, stderr, durationMs } = await runClaude({
|
|
139
139
|
args,
|
|
140
140
|
input: prompt,
|
|
141
141
|
timeoutMs: TIMEOUT_MS.async,
|
|
142
142
|
caller: 'async-task',
|
|
143
143
|
});
|
|
144
144
|
const startedAt = Date.now() - durationMs;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
catch (err) {
|
|
150
|
-
throw new Error(`async task parse failed: ${err.message}`);
|
|
145
|
+
const parsed = parseStreamJson(stdout);
|
|
146
|
+
if (!parsed) {
|
|
147
|
+
throw new Error(`async task stream-json produced no result event: ${stdout.slice(0, 200)}`);
|
|
151
148
|
}
|
|
152
|
-
if (parsed.
|
|
153
|
-
throw new Error(`async task bad output: ${parsed.result
|
|
149
|
+
if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
|
|
150
|
+
throw new Error(`async task bad output: ${parsed.result || stdout.slice(0, 200)}`);
|
|
154
151
|
}
|
|
155
152
|
const output = parsed.result.trim();
|
|
156
153
|
void logPrompt({
|
|
@@ -160,6 +157,8 @@ async function spawnClaudeForTask(task, prompt) {
|
|
|
160
157
|
input: prompt,
|
|
161
158
|
output,
|
|
162
159
|
durationMs,
|
|
160
|
+
stderr,
|
|
161
|
+
eventTypes: parsed.eventTypes,
|
|
163
162
|
});
|
|
164
163
|
return output;
|
|
165
164
|
}
|
|
@@ -275,3 +274,293 @@ function titleCaseSlug(slug) {
|
|
|
275
274
|
function truncate(s, n) {
|
|
276
275
|
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
277
276
|
}
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// BROWSER LANE
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// A second async lane dedicated to browser work. Key differences vs the
|
|
281
|
+
// general async lane above:
|
|
282
|
+
//
|
|
283
|
+
// - Concurrency is 1. Serialized against itself because (a) the shared
|
|
284
|
+
// Playwright MCP + Chrome is one physical resource, (b) the session below
|
|
285
|
+
// is persistent and --resume doesn't allow concurrent resumes.
|
|
286
|
+
// - One GLOBAL persistent session stored at storage/browser-session.json.
|
|
287
|
+
// First browser task bootstraps fresh (captures sessionId). Subsequent
|
|
288
|
+
// tasks spawn with --resume <sessionId>, so the browser Claude carries
|
|
289
|
+
// memory of prior tasks across runs.
|
|
290
|
+
// - Task description is added as a new user message to the persistent
|
|
291
|
+
// session. The worker sees the accumulated history automatically.
|
|
292
|
+
function browserSessionFilePath() {
|
|
293
|
+
return resolve(process.cwd(), config.memory.dir, 'browser-session.json');
|
|
294
|
+
}
|
|
295
|
+
function loadBrowserSession() {
|
|
296
|
+
const path = browserSessionFilePath();
|
|
297
|
+
if (!existsSync(path)) {
|
|
298
|
+
return { sessionId: null, createdAt: 0, lastUsedAt: 0, resumeCount: 0 };
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
302
|
+
return {
|
|
303
|
+
sessionId: parsed.sessionId ?? null,
|
|
304
|
+
createdAt: parsed.createdAt ?? 0,
|
|
305
|
+
lastUsedAt: parsed.lastUsedAt ?? 0,
|
|
306
|
+
resumeCount: parsed.resumeCount ?? 0,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return { sessionId: null, createdAt: 0, lastUsedAt: 0, resumeCount: 0 };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function saveBrowserSession(state) {
|
|
314
|
+
const path = browserSessionFilePath();
|
|
315
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
316
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
317
|
+
}
|
|
318
|
+
// Reset the browser session. Callable from outside if the session gets
|
|
319
|
+
// corrupted or we want a fresh start. Not wired into any command yet.
|
|
320
|
+
export function resetBrowserSession() {
|
|
321
|
+
saveBrowserSession({
|
|
322
|
+
sessionId: null,
|
|
323
|
+
createdAt: 0,
|
|
324
|
+
lastUsedAt: 0,
|
|
325
|
+
resumeCount: 0,
|
|
326
|
+
});
|
|
327
|
+
logger.info('browser session reset');
|
|
328
|
+
}
|
|
329
|
+
const browserQueue = fastq.promise(async (task) => {
|
|
330
|
+
inProgress.set(task.id, task);
|
|
331
|
+
try {
|
|
332
|
+
await runBrowserTask(task);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
logger.error({ err, id: task.id, jid: task.jid }, 'browser task failed unexpectedly');
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
inProgress.delete(task.id);
|
|
339
|
+
}
|
|
340
|
+
}, 1);
|
|
341
|
+
export function enqueueBrowserTask(input) {
|
|
342
|
+
const task = {
|
|
343
|
+
...input,
|
|
344
|
+
id: `browser-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
345
|
+
startedAt: Math.floor(Date.now() / 1000),
|
|
346
|
+
};
|
|
347
|
+
logger.info({
|
|
348
|
+
id: task.id,
|
|
349
|
+
jid: task.jid,
|
|
350
|
+
description: task.description.slice(0, 200),
|
|
351
|
+
}, 'browser task enqueued');
|
|
352
|
+
browserQueue.push(task).catch((err) => logger.error({ err, id: task.id }, 'browser queue push failed'));
|
|
353
|
+
return task;
|
|
354
|
+
}
|
|
355
|
+
function buildBrowserPrompt(task, isResume) {
|
|
356
|
+
// Framing tuned for the dedicated browser worker.
|
|
357
|
+
const lines = [
|
|
358
|
+
isResume
|
|
359
|
+
? `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).`
|
|
360
|
+
: `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).`,
|
|
361
|
+
``,
|
|
362
|
+
`TASK:`,
|
|
363
|
+
task.description,
|
|
364
|
+
``,
|
|
365
|
+
`ORIGINAL USER MESSAGE (for reference):`,
|
|
366
|
+
task.originatingMessage,
|
|
367
|
+
``,
|
|
368
|
+
`Sender: ${task.senderName ?? task.senderNumber}`,
|
|
369
|
+
``,
|
|
370
|
+
`HOW TO OUTPUT:`,
|
|
371
|
+
`- Write the full answer as a natural chat reply. Same voice as the main chat Claude, just delayed.`,
|
|
372
|
+
`- Open with a short "about the X you asked about..." reference — the owner may have asked for several things.`,
|
|
373
|
+
`- Concrete findings only. Numbers, names, dates. If you found 10 creators, list them.`,
|
|
374
|
+
`- Failure mode: page hung, login wall, bot-detection, empty feed — say so briefly. Do NOT fabricate.`,
|
|
375
|
+
``,
|
|
376
|
+
`BAIL CONDITIONS (stop and report, don't burn the clock):`,
|
|
377
|
+
`- Same tool call with same args retried 3 times → stuck, bail.`,
|
|
378
|
+
`- 3 consecutive empty/error responses from the site → site is throttling, bail.`,
|
|
379
|
+
`- Any single tool call running past 5 min → bail.`,
|
|
380
|
+
`- 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.`,
|
|
381
|
+
``,
|
|
382
|
+
`OPTIONAL MARKERS (at the END of your output):`,
|
|
383
|
+
`- [JOURNAL:<slug> — <one-line finding>] per finding that belongs in an active journal.`,
|
|
384
|
+
`- [JOURNAL-NEW:<slug> — <purpose>] if a clearly-recurring tracking surface doesn't have a journal yet.`,
|
|
385
|
+
`- [DIGEST: <reason>] if a durable fact about the owner/chat came up.`,
|
|
386
|
+
``,
|
|
387
|
+
`CONSTRAINTS:`,
|
|
388
|
+
`- Do NOT emit [ASYNC:...] or [ASYNC-BROWSER:...]. No recursion.`,
|
|
389
|
+
`- Markers are bonus persistence, not a substitute for the reply.`,
|
|
390
|
+
`- Stay fully in character (personality).`,
|
|
391
|
+
``,
|
|
392
|
+
`Do the work. Write the reply. Markers optional at the end.`,
|
|
393
|
+
];
|
|
394
|
+
return lines.join('\n');
|
|
395
|
+
}
|
|
396
|
+
function buildBrowserArgs(task, sessionId) {
|
|
397
|
+
const args = [
|
|
398
|
+
'-p',
|
|
399
|
+
'--output-format',
|
|
400
|
+
'stream-json',
|
|
401
|
+
'--model',
|
|
402
|
+
config.claude.model,
|
|
403
|
+
'--permission-mode',
|
|
404
|
+
'acceptEdits',
|
|
405
|
+
];
|
|
406
|
+
if (sessionId) {
|
|
407
|
+
// Resume — system prompt and memory-dirs are already baked into session
|
|
408
|
+
args.push('--resume', sessionId);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
// First call — bootstrap the persistent session
|
|
412
|
+
args.push('--append-system-prompt', systemPrompt());
|
|
413
|
+
for (const dir of config.claude.addDirs) {
|
|
414
|
+
args.push('--add-dir', resolve(process.cwd(), dir));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Memory + media dirs re-added each call (harmless if already baked; needed
|
|
418
|
+
// on fresh bootstrap; lets the browser worker Read updated memory files
|
|
419
|
+
// between turns).
|
|
420
|
+
args.push('--add-dir', resolve(process.cwd(), config.memory.dir));
|
|
421
|
+
args.push('--add-dir', resolve(process.cwd(), config.storage.mediaDir));
|
|
422
|
+
if (task.allowedTools &&
|
|
423
|
+
task.allowedTools !== 'all' &&
|
|
424
|
+
task.allowedTools.length > 0) {
|
|
425
|
+
args.push('--allowedTools', task.allowedTools.join(','));
|
|
426
|
+
}
|
|
427
|
+
return args;
|
|
428
|
+
}
|
|
429
|
+
async function runBrowserTask(task) {
|
|
430
|
+
const session = loadBrowserSession();
|
|
431
|
+
const isResume = !!session.sessionId;
|
|
432
|
+
const prompt = buildBrowserPrompt(task, isResume);
|
|
433
|
+
const args = buildBrowserArgs(task, session.sessionId);
|
|
434
|
+
const startedAtMs = Date.now();
|
|
435
|
+
const elapsedLog = () => `${Math.round((Date.now() - task.startedAt * 1000) / 1000)}s`;
|
|
436
|
+
let stdout;
|
|
437
|
+
let stderr;
|
|
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
|
+
stderr = result.stderr;
|
|
448
|
+
durationMs = result.durationMs;
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'browser task claude call failed');
|
|
452
|
+
await initiate({
|
|
453
|
+
jid: task.jid,
|
|
454
|
+
text: `Heads up: the browser task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const parsed = parseStreamJson(stdout);
|
|
459
|
+
if (!parsed) {
|
|
460
|
+
logger.error({ id: task.id }, 'browser task stream-json produced no result event');
|
|
461
|
+
await initiate({
|
|
462
|
+
jid: task.jid,
|
|
463
|
+
text: `Heads up: the browser task "${truncate(task.description, 80)}" returned an unparseable response.`,
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
|
|
468
|
+
logger.error({ id: task.id, subtype: parsed.subtype, isError: parsed.isError }, 'browser task bad output');
|
|
469
|
+
await initiate({
|
|
470
|
+
jid: task.jid,
|
|
471
|
+
text: `Heads up: the browser task "${truncate(task.description, 80)}" returned an error.`,
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
// Persist the session id. On first call Claude returns the new sessionId;
|
|
476
|
+
// on resume it may return the same or a rotated one.
|
|
477
|
+
const returnedSessionId = parsed.sessionId;
|
|
478
|
+
if (returnedSessionId) {
|
|
479
|
+
const now = Math.floor(Date.now() / 1000);
|
|
480
|
+
saveBrowserSession({
|
|
481
|
+
sessionId: returnedSessionId,
|
|
482
|
+
createdAt: session.createdAt || now,
|
|
483
|
+
lastUsedAt: now,
|
|
484
|
+
resumeCount: (session.resumeCount ?? 0) + (isResume ? 1 : 0),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
void logPrompt({
|
|
488
|
+
ts: Math.floor(startedAtMs / 1000),
|
|
489
|
+
caller: 'browser-task',
|
|
490
|
+
args,
|
|
491
|
+
input: prompt,
|
|
492
|
+
output: parsed.result,
|
|
493
|
+
sessionId: returnedSessionId ?? undefined,
|
|
494
|
+
durationMs,
|
|
495
|
+
stderr,
|
|
496
|
+
eventTypes: parsed.eventTypes,
|
|
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
|
}
|