@c4t4/heyamigo 0.3.0 → 0.6.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.
@@ -0,0 +1,215 @@
1
+ import { spawn } from 'child_process';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { config } from '../config.js';
5
+ import fastq from 'fastq';
6
+ import { initiate } from '../gateway/outgoing.js';
7
+ import { logger } from '../logger.js';
8
+ import { logPrompt } from '../promptlog.js';
9
+ // Concurrency: how many async Claude workers can run simultaneously.
10
+ // Start conservative — each process is expensive (Playwright, multi-minute runs).
11
+ // Tune via config.asyncTasks.concurrency once we have real usage data.
12
+ const CONCURRENCY = 3;
13
+ // In-memory registry of tasks currently executing. Not persisted across
14
+ // restarts — on reboot, any in-flight async work is silently dropped.
15
+ // We expose listInProgress() so the chat preamble can show "in progress"
16
+ // hints to the main Claude.
17
+ const inProgress = new Map();
18
+ const queue = fastq.promise(async (task) => {
19
+ inProgress.set(task.id, task);
20
+ try {
21
+ await runTask(task);
22
+ }
23
+ catch (err) {
24
+ logger.error({ err, id: task.id, jid: task.jid }, 'async task failed unexpectedly');
25
+ }
26
+ finally {
27
+ inProgress.delete(task.id);
28
+ }
29
+ }, CONCURRENCY);
30
+ export function enqueueAsyncTask(input) {
31
+ const task = {
32
+ ...input,
33
+ id: `async-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
34
+ startedAt: Math.floor(Date.now() / 1000),
35
+ };
36
+ logger.info({
37
+ id: task.id,
38
+ jid: task.jid,
39
+ description: task.description.slice(0, 200),
40
+ }, 'async task enqueued');
41
+ queue.push(task).catch((err) => logger.error({ err, id: task.id }, 'async queue push failed'));
42
+ return task;
43
+ }
44
+ export function listAsyncTasks(jid) {
45
+ const all = Array.from(inProgress.values());
46
+ if (!jid)
47
+ return all;
48
+ return all.filter((t) => t.jid === jid);
49
+ }
50
+ // ---------- task runner ----------
51
+ let cachedSystemPrompt = null;
52
+ function systemPrompt() {
53
+ if (cachedSystemPrompt !== null)
54
+ return cachedSystemPrompt;
55
+ const personality = readFileSync(resolve(process.cwd(), config.claude.personalityFile), 'utf-8');
56
+ let memoryInstructions = '';
57
+ try {
58
+ memoryInstructions = readFileSync(resolve(process.cwd(), config.memory.instructionsFile), 'utf-8');
59
+ }
60
+ catch {
61
+ // optional
62
+ }
63
+ cachedSystemPrompt = memoryInstructions
64
+ ? `${personality}\n\n---\n\n${memoryInstructions}`
65
+ : personality;
66
+ return cachedSystemPrompt;
67
+ }
68
+ export function reloadAsyncSystemPrompt() {
69
+ cachedSystemPrompt = null;
70
+ }
71
+ function buildPrompt(task) {
72
+ const lines = [
73
+ `You are running a BACKGROUND TASK for the owner. The chat already got your ack reply. Your only job now is to do the work and output the final message to send them.`,
74
+ ``,
75
+ `TASK:`,
76
+ task.description,
77
+ ``,
78
+ `ORIGINAL USER MESSAGE (for reference):`,
79
+ task.originatingMessage,
80
+ ``,
81
+ `Sender: ${task.senderName ?? task.senderNumber}`,
82
+ ``,
83
+ `RULES:`,
84
+ `- Stay fully in character (personality file). This is not customer service.`,
85
+ `- Do the real work. Use tools (browser, etc.) as needed.`,
86
+ `- When done, output ONLY the message to send the user. No preamble, no "here's what I found:" framing unless that's the message itself.`,
87
+ `- Do NOT emit any [DIGEST:...], [JOURNAL:...], [ASYNC:...], or other markers. This is the final output.`,
88
+ `- Start the message with a short reference to what you were working on so the user knows which task this is about (e.g. "About the TikTok scrape: ..."). They may have asked for multiple things.`,
89
+ `- If the task is impossible or the tools failed, say so honestly and briefly. Don't fabricate.`,
90
+ ``,
91
+ `Output the final user-facing message now.`,
92
+ ];
93
+ return lines.join('\n');
94
+ }
95
+ function buildArgs(task) {
96
+ const args = [
97
+ '-p',
98
+ '--output-format',
99
+ 'json',
100
+ '--model',
101
+ config.claude.model,
102
+ '--permission-mode',
103
+ 'acceptEdits',
104
+ '--append-system-prompt',
105
+ systemPrompt(),
106
+ ];
107
+ for (const dir of config.claude.addDirs) {
108
+ args.push('--add-dir', resolve(process.cwd(), dir));
109
+ }
110
+ args.push('--add-dir', resolve(process.cwd(), config.memory.dir));
111
+ args.push('--add-dir', resolve(process.cwd(), config.storage.mediaDir));
112
+ if (task.allowedTools &&
113
+ task.allowedTools !== 'all' &&
114
+ task.allowedTools.length > 0) {
115
+ args.push('--allowedTools', task.allowedTools.join(','));
116
+ }
117
+ return args;
118
+ }
119
+ async function spawnClaudeForTask(task, prompt) {
120
+ const args = buildArgs(task);
121
+ const startedAt = Date.now();
122
+ return new Promise((resolvePromise, rejectPromise) => {
123
+ const child = spawn('claude', args, {
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ cwd: process.cwd(),
126
+ });
127
+ let stdout = '';
128
+ let stderr = '';
129
+ child.stdout.on('data', (c) => {
130
+ stdout += c.toString('utf-8');
131
+ });
132
+ child.stderr.on('data', (c) => {
133
+ stderr += c.toString('utf-8');
134
+ });
135
+ const logFail = (error) => void logPrompt({
136
+ ts: Math.floor(startedAt / 1000),
137
+ caller: 'async-task',
138
+ args,
139
+ input: prompt,
140
+ error,
141
+ durationMs: Date.now() - startedAt,
142
+ });
143
+ child.on('error', (err) => {
144
+ logFail(`spawn failed: ${err.message}`);
145
+ rejectPromise(err);
146
+ });
147
+ child.on('close', (code) => {
148
+ if (code !== 0) {
149
+ logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
150
+ return rejectPromise(new Error(`async task exit ${code}`));
151
+ }
152
+ try {
153
+ const parsed = JSON.parse(stdout);
154
+ if (parsed.is_error ||
155
+ parsed.subtype !== 'success' ||
156
+ !parsed.result) {
157
+ logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
158
+ return rejectPromise(new Error('async task bad output'));
159
+ }
160
+ const output = parsed.result.trim();
161
+ void logPrompt({
162
+ ts: Math.floor(startedAt / 1000),
163
+ caller: 'async-task',
164
+ args,
165
+ input: prompt,
166
+ output,
167
+ durationMs: Date.now() - startedAt,
168
+ });
169
+ resolvePromise(output);
170
+ }
171
+ catch (err) {
172
+ logFail(`parse failed: ${err.message}`);
173
+ rejectPromise(err);
174
+ }
175
+ });
176
+ child.stdin.write(prompt);
177
+ child.stdin.end();
178
+ });
179
+ }
180
+ async function runTask(task) {
181
+ const prompt = buildPrompt(task);
182
+ const elapsedLog = () => `${Math.round((Date.now() - task.startedAt * 1000) / 1000)}s`;
183
+ let output;
184
+ try {
185
+ output = await spawnClaudeForTask(task, prompt);
186
+ }
187
+ catch (err) {
188
+ logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'async task claude call failed');
189
+ await initiate({
190
+ jid: task.jid,
191
+ text: `Heads up: the background task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
192
+ });
193
+ return;
194
+ }
195
+ // Strip any accidental trailing markers Claude emitted despite instructions.
196
+ // Import lazily to avoid an import cycle (digest-flag already stands alone,
197
+ // but being explicit here keeps this module independent).
198
+ const { extractFlags } = await import('../memory/digest-flag.js');
199
+ const { clean } = extractFlags(output);
200
+ if (!clean.trim()) {
201
+ logger.warn({ id: task.id, jid: task.jid }, 'async task produced empty output after flag strip');
202
+ return;
203
+ }
204
+ const sent = await initiate({ jid: task.jid, text: clean });
205
+ logger.info({
206
+ id: task.id,
207
+ jid: task.jid,
208
+ sent,
209
+ elapsed: elapsedLog(),
210
+ chars: clean.length,
211
+ }, 'async task completed');
212
+ }
213
+ function truncate(s, n) {
214
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
215
+ }
@@ -1,19 +1,24 @@
1
1
  import { askClaude } from '../ai/claude.js';
2
2
  import { clearSession, setSession, setUsage } from '../ai/sessions.js';
3
+ import { config } from '../config.js';
3
4
  import { logger } from '../logger.js';
4
5
  import { extractFlags } from '../memory/digest-flag.js';
5
- import { appendEntry, createJournal, getJournal, isValidSlug, updateJournalStatus, } from '../memory/journals.js';
6
+ import { appendEntry, createJournal, getJournal, isValidSlug, } from '../memory/journals.js';
6
7
  import { scheduleDigest } from '../memory/scheduler.js';
8
+ import { enqueueAsyncTask } from './async-tasks.js';
7
9
  function isStaleSessionError(err) {
8
10
  return (err instanceof Error &&
9
11
  err.message.includes('No conversation found'));
10
12
  }
11
13
  async function callClaude(job) {
14
+ const startedAt = Date.now();
15
+ const wasFresh = !job.sessionId;
12
16
  const { reply, sessionId, usage } = await askClaude({
13
17
  input: job.input,
14
18
  sessionId: job.sessionId,
15
19
  allowedTools: job.allowedTools,
16
20
  });
21
+ const durationMs = Date.now() - startedAt;
17
22
  if (!job.sessionId) {
18
23
  setSession(job.jid, sessionId);
19
24
  }
@@ -26,7 +31,7 @@ async function callClaude(job) {
26
31
  totalContextTokens,
27
32
  updatedAt: Math.floor(Date.now() / 1000),
28
33
  });
29
- const { clean, digest, journals, lifecycleOps } = extractFlags(reply);
34
+ const { clean, digest, journals, journalCreates, asyncTasks } = extractFlags(reply);
30
35
  if (digest) {
31
36
  logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
32
37
  scheduleDigest({
@@ -35,43 +40,27 @@ async function callClaude(job) {
35
40
  reason: digest,
36
41
  });
37
42
  }
38
- // Lifecycle ops run BEFORE entry appends so that a reply creating a new
39
- // journal AND flagging its first entry in the same turn works correctly.
40
- for (const op of lifecycleOps) {
43
+ // Creates run BEFORE entry appends so that a reply creating a new journal
44
+ // AND flagging its first entry in the same turn works correctly.
45
+ for (const op of journalCreates) {
41
46
  if (!isValidSlug(op.slug)) {
42
- logger.warn({ op, jid: job.jid }, 'journal lifecycle op: invalid slug, dropped');
47
+ logger.warn({ op, jid: job.jid }, 'JOURNAL-NEW: invalid slug, dropped');
43
48
  continue;
44
49
  }
45
50
  try {
46
- if (op.kind === 'new') {
47
- if (getJournal(op.slug)) {
48
- logger.info({ slug: op.slug }, 'JOURNAL-NEW for existing slug, ignored');
49
- continue;
50
- }
51
- createJournal({
52
- slug: op.slug,
53
- name: titleCase(op.slug),
54
- purpose: op.purpose,
55
- });
56
- logger.info({ slug: op.slug, jid: job.jid }, 'journal created via bot marker');
57
- }
58
- else {
59
- const status = op.kind === 'pause'
60
- ? 'paused'
61
- : op.kind === 'archive'
62
- ? 'archived'
63
- : 'active';
64
- const updated = updateJournalStatus(op.slug, status);
65
- if (updated) {
66
- logger.info({ slug: op.slug, status, jid: job.jid }, 'journal status updated via bot marker');
67
- }
68
- else {
69
- logger.warn({ op, jid: job.jid }, 'journal lifecycle op: unknown slug, dropped');
70
- }
51
+ if (getJournal(op.slug)) {
52
+ logger.info({ slug: op.slug }, 'JOURNAL-NEW for existing slug, ignored');
53
+ continue;
71
54
  }
55
+ createJournal({
56
+ slug: op.slug,
57
+ name: titleCase(op.slug),
58
+ purpose: op.purpose,
59
+ });
60
+ logger.info({ slug: op.slug, jid: job.jid }, 'journal created via bot marker');
72
61
  }
73
62
  catch (err) {
74
- logger.error({ err, op, jid: job.jid }, 'journal lifecycle op failed');
63
+ logger.error({ err, op, jid: job.jid }, 'JOURNAL-NEW failed');
75
64
  }
76
65
  }
77
66
  for (const j of journals) {
@@ -85,7 +74,34 @@ async function callClaude(job) {
85
74
  logger.warn({ slug: j.slug, jid: job.jid }, 'JOURNAL flag pointed at unknown slug, dropped');
86
75
  }
87
76
  }
88
- return { reply: clean };
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.
81
+ for (const t of asyncTasks) {
82
+ enqueueAsyncTask({
83
+ jid: job.jid,
84
+ senderNumber: job.senderNumber,
85
+ description: t.description,
86
+ originatingMessage: job.text,
87
+ allowedTools: job.allowedTools ?? 'all',
88
+ });
89
+ }
90
+ return {
91
+ reply: clean,
92
+ stats: {
93
+ durationMs,
94
+ inputTokens: usage.inputTokens,
95
+ outputTokens: usage.outputTokens,
96
+ cacheReadTokens: usage.cacheReadTokens,
97
+ totalContextTokens,
98
+ contextWindow: config.claude.contextWindow,
99
+ fresh: wasFresh,
100
+ hasDigest: digest !== null,
101
+ journalSlugs: journals.map((j) => j.slug),
102
+ asyncCount: asyncTasks.length,
103
+ },
104
+ };
89
105
  }
90
106
  function titleCase(slug) {
91
107
  return slug
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.3.0",
3
+ "version": "0.6.1",
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",