@c4t4/heyamigo 0.8.7 → 0.8.8

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/dist/ai/claude.js CHANGED
@@ -104,3 +104,105 @@ export async function askClaude(params) {
104
104
  });
105
105
  return result;
106
106
  }
107
+ // Claude's per-mode permission + tool defaults. The caller can still override
108
+ // allowedTools explicitly; mode just sets the floor.
109
+ function permissionModeFor(mode) {
110
+ switch (mode) {
111
+ case 'read-only':
112
+ return 'default'; // prompts on writes; we layer allowedTools to enforce
113
+ case 'auto':
114
+ case 'full':
115
+ return 'acceptEdits';
116
+ }
117
+ }
118
+ function defaultAllowedToolsFor(mode) {
119
+ if (mode === 'read-only')
120
+ return ['Read', 'Grep', 'Glob', 'WebFetch'];
121
+ return undefined; // no restriction
122
+ }
123
+ function buildTaskArgs(params) {
124
+ const args = [
125
+ '-p',
126
+ '--output-format',
127
+ 'stream-json',
128
+ '--verbose',
129
+ '--model',
130
+ config.claude.model,
131
+ '--permission-mode',
132
+ permissionModeFor(params.mode),
133
+ ];
134
+ if (params.sessionId) {
135
+ args.push('--resume', params.sessionId);
136
+ }
137
+ else if (params.includeSystemPrompt) {
138
+ args.push('--append-system-prompt', systemPrompt());
139
+ }
140
+ // On fresh sessions, fold the configured baseline read dirs in too. On
141
+ // resume Claude already has them baked into the session state.
142
+ if (!params.sessionId && params.includeSystemPrompt) {
143
+ for (const dir of config.claude.addDirs) {
144
+ args.push('--add-dir', resolve(process.cwd(), dir));
145
+ }
146
+ }
147
+ for (const dir of params.addDirs ?? []) {
148
+ args.push('--add-dir', resolve(process.cwd(), dir));
149
+ }
150
+ const allowedTools = params.allowedTools && params.allowedTools !== 'all'
151
+ ? params.allowedTools
152
+ : defaultAllowedToolsFor(params.mode);
153
+ if (allowedTools && allowedTools.length > 0) {
154
+ args.push('--allowedTools', allowedTools.join(','));
155
+ }
156
+ return args;
157
+ }
158
+ function laneTimeoutMs(lane) {
159
+ return TIMEOUT_MS[lane];
160
+ }
161
+ export async function runClaudeTask(params) {
162
+ const args = buildTaskArgs(params);
163
+ const { stdout, stderr, durationMs } = await runClaude({
164
+ args,
165
+ input: params.input,
166
+ timeoutMs: laneTimeoutMs(params.lane),
167
+ caller: params.caller,
168
+ });
169
+ const startedAt = Date.now() - durationMs;
170
+ const parsed = parseStreamJson(stdout);
171
+ if (!parsed) {
172
+ throw new Error(`${params.caller} stream-json produced no result event: ${stdout.slice(0, 200)}`);
173
+ }
174
+ if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
175
+ throw new Error(`${params.caller} bad output: ${parsed.result || stdout.slice(0, 200)}`);
176
+ }
177
+ const reply = parsed.result.trim();
178
+ const usage = {
179
+ inputTokens: parsed.usage?.input_tokens ?? 0,
180
+ cacheReadTokens: parsed.usage?.cache_read_input_tokens ?? 0,
181
+ cacheCreationTokens: parsed.usage?.cache_creation_input_tokens ?? 0,
182
+ outputTokens: parsed.usage?.output_tokens ?? 0,
183
+ numTurns: parsed.numTurns ?? 0,
184
+ };
185
+ void logPrompt({
186
+ ts: Math.floor(startedAt / 1000),
187
+ caller: params.caller,
188
+ args,
189
+ input: params.input,
190
+ output: reply,
191
+ sessionId: parsed.sessionId ?? undefined,
192
+ usage,
193
+ durationMs,
194
+ stderr,
195
+ eventTypes: parsed.eventTypes,
196
+ });
197
+ return {
198
+ reply,
199
+ sessionId: parsed.sessionId ?? undefined,
200
+ usage,
201
+ };
202
+ }
203
+ export const claudeProvider = {
204
+ name: 'claude',
205
+ ask: askClaude,
206
+ runTask: runClaudeTask,
207
+ reloadSystemPrompt,
208
+ };
@@ -0,0 +1,224 @@
1
+ // Codex CLI provider. Maps the neutral AiProvider contract onto
2
+ // `codex exec --json`. Flag names match the public CLI docs as of writing
3
+ // (developers.openai.com/codex/cli/features); if your local Codex version
4
+ // uses different flags, the small surface here is the only place to adjust.
5
+ //
6
+ // What's wired:
7
+ // - exec mode with --json (NDJSON event stream on stdout)
8
+ // - --add-dir for extra writable roots
9
+ // - --sandbox-mode for tier (read-only / workspace-write / danger-full-access)
10
+ // - --resume <id> for session continuation
11
+ // - prompt passed on stdin (matches the spawn plumbing that already
12
+ // pipes input to child.stdin)
13
+ //
14
+ // What's deliberately coarse:
15
+ // - allowedTools is ignored on this provider. Codex has no per-tool
16
+ // allowlist; the sandbox mode is the only knob. The mode argument
17
+ // covers the practical cases (read vs. write vs. full).
18
+ import { readFileSync } from 'fs';
19
+ import { resolve } from 'path';
20
+ import { config } from '../config.js';
21
+ import { logger } from '../logger.js';
22
+ import { logPrompt } from '../promptlog.js';
23
+ import { runClaude, TIMEOUT_MS } from './spawn.js';
24
+ let cachedSystemPrompt = null;
25
+ function systemPrompt() {
26
+ if (cachedSystemPrompt !== null)
27
+ return cachedSystemPrompt;
28
+ const personality = readFileSync(resolve(process.cwd(), config.claude.personalityFile), 'utf-8');
29
+ let memoryInstructions = '';
30
+ try {
31
+ memoryInstructions = readFileSync(resolve(process.cwd(), config.memory.instructionsFile), 'utf-8');
32
+ }
33
+ catch {
34
+ // memory instructions optional
35
+ }
36
+ cachedSystemPrompt = memoryInstructions
37
+ ? `${personality}\n\n---\n\n${memoryInstructions}`
38
+ : personality;
39
+ return cachedSystemPrompt;
40
+ }
41
+ function reloadSystemPrompt() {
42
+ cachedSystemPrompt = null;
43
+ }
44
+ // Codex sandbox vocabulary. The CLI flag is --sandbox-mode (or --sandbox in
45
+ // some builds); values are: read-only, workspace-write, danger-full-access.
46
+ function sandboxFor(mode) {
47
+ switch (mode) {
48
+ case 'read-only':
49
+ return 'read-only';
50
+ case 'auto':
51
+ return 'workspace-write';
52
+ case 'full':
53
+ return 'danger-full-access';
54
+ }
55
+ }
56
+ function laneTimeoutMs(lane) {
57
+ return TIMEOUT_MS[lane];
58
+ }
59
+ function buildExecArgs(params) {
60
+ const args = ['exec', '--json'];
61
+ args.push('--sandbox-mode', sandboxFor(params.mode));
62
+ if (params.sessionId) {
63
+ // Resume keeps the prior conversation; system prompt and add-dirs
64
+ // were baked in on the original turn.
65
+ args.push('--resume', params.sessionId);
66
+ }
67
+ else {
68
+ for (const dir of params.addDirs ?? []) {
69
+ args.push('--add-dir', resolve(process.cwd(), dir));
70
+ }
71
+ if (params.includeSystemPrompt) {
72
+ // Codex doesn't have Claude's --append-system-prompt. The portable
73
+ // approach is to inline the personality at the top of the prompt.
74
+ // (An alternative is writing AGENTS.md into cwd; we don't do that
75
+ // here because it'd mutate the repo.)
76
+ params.prompt = `${systemPrompt()}\n\n---\n\n${params.prompt}`;
77
+ }
78
+ }
79
+ // Prompt as positional arg. `codex exec` reads stdin only with `-`, and
80
+ // passing it positionally avoids ambiguity with the spawn pipe.
81
+ args.push(params.prompt);
82
+ return args;
83
+ }
84
+ function parseCodexOutput(stdout) {
85
+ const events = [];
86
+ for (const line of stdout.split(/\r?\n/)) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed)
89
+ continue;
90
+ try {
91
+ events.push(JSON.parse(trimmed));
92
+ }
93
+ catch {
94
+ // Codex occasionally emits non-JSON preamble; skip it.
95
+ }
96
+ }
97
+ if (events.length === 0)
98
+ return null;
99
+ // Find the final agent message. Codex labels it variously across
100
+ // versions — try the common shapes in order.
101
+ let reply = null;
102
+ for (let i = events.length - 1; i >= 0; i--) {
103
+ const ev = events[i];
104
+ if (ev.msg?.type === 'agent_message' &&
105
+ typeof ev.msg.message === 'string') {
106
+ reply = ev.msg.message;
107
+ break;
108
+ }
109
+ if (typeof ev.message === 'string') {
110
+ reply = ev.message;
111
+ break;
112
+ }
113
+ if (typeof ev.text === 'string') {
114
+ reply = ev.text;
115
+ break;
116
+ }
117
+ }
118
+ if (reply === null)
119
+ return null;
120
+ // Session id — Codex uses different field names across versions.
121
+ let sessionId;
122
+ for (const ev of events) {
123
+ const id = ev.session_id ?? ev.conversation_id ?? ev.response_id;
124
+ if (typeof id === 'string' && id) {
125
+ sessionId = id;
126
+ break;
127
+ }
128
+ }
129
+ // Usage — last event with a usage object wins (final turn totals).
130
+ let inputTokens = 0;
131
+ let outputTokens = 0;
132
+ let cacheReadTokens = 0;
133
+ for (let i = events.length - 1; i >= 0; i--) {
134
+ const u = events[i].usage;
135
+ if (u) {
136
+ inputTokens = u.input_tokens ?? 0;
137
+ outputTokens = u.output_tokens ?? 0;
138
+ cacheReadTokens = u.cached_input_tokens ?? 0;
139
+ break;
140
+ }
141
+ }
142
+ return {
143
+ reply: reply.trim(),
144
+ sessionId,
145
+ usage: {
146
+ inputTokens,
147
+ cacheReadTokens,
148
+ cacheCreationTokens: 0,
149
+ outputTokens,
150
+ numTurns: 0,
151
+ },
152
+ };
153
+ }
154
+ async function runCodexTask(params) {
155
+ const args = buildExecArgs({
156
+ mode: params.mode,
157
+ addDirs: params.addDirs,
158
+ sessionId: params.sessionId,
159
+ includeSystemPrompt: params.includeSystemPrompt,
160
+ prompt: params.input,
161
+ });
162
+ logger.debug({ caller: params.caller, resume: !!params.sessionId }, 'spawning codex exec');
163
+ // input is empty here — the prompt rides in argv (Codex exec semantics).
164
+ // Empty stdin end() is harmless.
165
+ const { stdout, stderr, durationMs } = await runClaude({
166
+ args,
167
+ input: '',
168
+ timeoutMs: laneTimeoutMs(params.lane),
169
+ caller: params.caller,
170
+ bin: 'codex',
171
+ });
172
+ const startedAt = Date.now() - durationMs;
173
+ const parsed = parseCodexOutput(stdout);
174
+ if (!parsed) {
175
+ throw new Error(`codex produced no parseable result; stdout: ${stdout.slice(0, 500)}`);
176
+ }
177
+ void logPrompt({
178
+ ts: Math.floor(startedAt / 1000),
179
+ caller: params.caller,
180
+ args,
181
+ input: params.input,
182
+ output: parsed.reply,
183
+ sessionId: parsed.sessionId,
184
+ usage: parsed.usage,
185
+ durationMs,
186
+ stderr,
187
+ });
188
+ return parsed;
189
+ }
190
+ async function askCodex(params) {
191
+ const result = await runCodexTask({
192
+ input: params.input,
193
+ caller: 'worker',
194
+ mode: 'auto',
195
+ lane: 'main',
196
+ sessionId: params.sessionId,
197
+ includeSystemPrompt: true,
198
+ allowedTools: params.allowedTools,
199
+ addDirs: [
200
+ config.memory.dir,
201
+ config.storage.mediaDir,
202
+ ],
203
+ });
204
+ if (!result.sessionId) {
205
+ throw new Error('codex ask: response missing session id');
206
+ }
207
+ return {
208
+ reply: result.reply,
209
+ sessionId: result.sessionId,
210
+ usage: result.usage ?? {
211
+ inputTokens: 0,
212
+ cacheReadTokens: 0,
213
+ cacheCreationTokens: 0,
214
+ outputTokens: 0,
215
+ numTurns: 0,
216
+ },
217
+ };
218
+ }
219
+ export const codexProvider = {
220
+ name: 'codex',
221
+ ask: askCodex,
222
+ runTask: runCodexTask,
223
+ reloadSystemPrompt,
224
+ };
@@ -0,0 +1,9 @@
1
+ // Provider abstraction for the user-facing chat ask path. Lets the worker
2
+ // route conversation turns to either Claude or Codex (or any future CLI)
3
+ // without knowing the wire details.
4
+ //
5
+ // Scope: covers the interactive worker call (one turn in, one turn out, with
6
+ // resumable session ids and per-role tool gating). Memory digests, async
7
+ // tasks, and the journal observers stay on `runClaude` directly — they're
8
+ // Claude-specific batch pipelines and not in this interface.
9
+ export {};
@@ -0,0 +1,18 @@
1
+ import { config } from '../config.js';
2
+ import { claudeProvider } from './claude.js';
3
+ import { codexProvider } from './codex.js';
4
+ const REGISTRY = {
5
+ claude: claudeProvider,
6
+ codex: codexProvider,
7
+ };
8
+ // Resolve the active provider. Defaults to claude if no override is set in
9
+ // config; pass an explicit name to force one (useful for per-role routing
10
+ // once access.json learns about it).
11
+ export function getProvider(name) {
12
+ const resolved = name ?? config.ai.provider;
13
+ return REGISTRY[resolved];
14
+ }
15
+ export function reloadAllSystemPrompts() {
16
+ for (const p of Object.values(REGISTRY))
17
+ p.reloadSystemPrompt();
18
+ }
package/dist/ai/spawn.js CHANGED
@@ -100,23 +100,26 @@ function killGroup(child, signal) {
100
100
  // should go through this.
101
101
  export async function runClaude(opts) {
102
102
  const { args, input, timeoutMs, caller } = opts;
103
+ const bin = opts.bin ?? 'claude';
103
104
  const startedAt = Date.now();
104
105
  return new Promise((resolvePromise, rejectPromise) => {
105
- const child = spawn('claude', args, {
106
+ const env = { ...process.env };
107
+ if (bin === 'claude') {
108
+ // ANTHROPIC_LOG=debug surfaces the SDK's HTTP layer to stderr:
109
+ // request URLs, status codes, retries, rate-limit notices. We
110
+ // capture stderr and put a truncated copy into the promptlog so
111
+ // we can diagnose API hangs/rate-limits post-mortem instead of
112
+ // staring at "Claude subprocess is idle, why?". Only meaningful
113
+ // for the Anthropic CLI; other providers ignore it.
114
+ env.ANTHROPIC_LOG = process.env.ANTHROPIC_LOG ?? 'debug';
115
+ }
116
+ const child = spawn(bin, args, {
106
117
  stdio: ['pipe', 'pipe', 'pipe'],
107
118
  cwd: opts.cwd ?? process.cwd(),
108
119
  // detached:true puts the child in its own process group, so killGroup
109
120
  // can SIGTERM the whole tree (Playwright MCP, Chromium, etc.) at once.
110
121
  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
- },
122
+ env,
120
123
  });
121
124
  let stdout = '';
122
125
  let stderr = '';
@@ -153,7 +156,7 @@ export async function runClaude(opts) {
153
156
  settled = true;
154
157
  clearTimeout(timer);
155
158
  logFail(`spawn failed: ${err.message}`);
156
- rejectPromise(new ClaudeSpawnError(caller, `claude spawn failed: ${err.message}`));
159
+ rejectPromise(new ClaudeSpawnError(caller, `${bin} spawn failed: ${err.message}`));
157
160
  });
158
161
  child.on('close', (code, signal) => {
159
162
  if (settled)
@@ -167,7 +170,7 @@ export async function runClaude(opts) {
167
170
  }
168
171
  if (code !== 0) {
169
172
  logFail(`exit ${code}: ${stderr.slice(0, 500)}`);
170
- return rejectPromise(new ClaudeSpawnError(caller, `claude exited with code ${code}: ${stderr.slice(0, 500)}`));
173
+ return rejectPromise(new ClaudeSpawnError(caller, `${bin} exited with code ${code}: ${stderr.slice(0, 500)}`));
171
174
  }
172
175
  resolvePromise({ stdout, stderr, durationMs });
173
176
  });
package/dist/cli/setup.js CHANGED
@@ -18,6 +18,41 @@ function which(bin) {
18
18
  const r = run(`which ${bin}`);
19
19
  return r.ok ? r.output : null;
20
20
  }
21
+ // Idempotently add the Playwright MCP entry to ~/.codex/config.toml so the
22
+ // Codex CLI auto-launches the same MCP server Claude uses. We don't have a
23
+ // TOML parser available; the entry has a fixed, simple shape so a text-level
24
+ // presence check + append is safe enough here. Returns true if the entry was
25
+ // added or already present, false on write failure.
26
+ function addPlaywrightToCodexConfig(cdpUrl) {
27
+ const codexDir = resolve(homedir(), '.codex');
28
+ const configPath = resolve(codexDir, 'config.toml');
29
+ const block = [
30
+ '',
31
+ '[mcp_servers.playwright]',
32
+ 'command = "npx"',
33
+ `args = ["@playwright/mcp@latest", "--cdp-endpoint", "${cdpUrl}"]`,
34
+ '',
35
+ ].join('\n');
36
+ try {
37
+ mkdirSync(codexDir, { recursive: true });
38
+ let existing = '';
39
+ if (existsSync(configPath)) {
40
+ existing = readFileSync(configPath, 'utf-8');
41
+ // Match both [mcp_servers.playwright] and [mcp_servers."playwright"].
42
+ if (/\[mcp_servers\.(?:"?)playwright(?:"?)\]/.test(existing)) {
43
+ return true;
44
+ }
45
+ }
46
+ const next = existing.endsWith('\n') || existing === ''
47
+ ? existing + block
48
+ : existing + '\n' + block;
49
+ writeFileSync(configPath, next, 'utf-8');
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
21
56
  function runLive(cmd) {
22
57
  const result = spawnSync('sh', ['-c', cmd], { stdio: 'inherit' });
23
58
  return result.status === 0;
@@ -296,16 +331,26 @@ export async function runSetup() {
296
331
  if (!isLinux) {
297
332
  p.log.warning('Automated browser setup is available on Linux only. ' +
298
333
  'On macOS/Windows: start Chrome with --remote-debugging-port=9222 manually, ' +
299
- 'then run: claude mcp add playwright -- npx @playwright/mcp@latest --cdp-endpoint "http://localhost:9222"');
334
+ 'then for Claude: claude mcp add playwright -- npx @playwright/mcp@latest --cdp-endpoint "http://localhost:9222"; ' +
335
+ 'for Codex: add [mcp_servers.playwright] to ~/.codex/config.toml.');
300
336
  }
301
337
  else {
302
338
  // ── Check if already running ─────────────────────────────
303
339
  const cdpUrl = 'http://localhost:9222';
304
340
  const alreadyRunning = run(`curl -s '${cdpUrl}/json/version'`);
305
341
  const mcpConfigured = run('claude mcp list 2>/dev/null').output.includes('playwright');
342
+ const hasCodex = !!which('codex');
306
343
  if (alreadyRunning.ok && alreadyRunning.output.includes('Browser') && mcpConfigured) {
307
344
  p.log.success('Chrome already running (localhost:9222)');
308
345
  p.log.success('Claude already connected to Chrome');
346
+ if (hasCodex) {
347
+ if (addPlaywrightToCodexConfig(cdpUrl)) {
348
+ p.log.success('Codex connected to Chrome (~/.codex/config.toml)');
349
+ }
350
+ else {
351
+ p.log.warning('Could not write ~/.codex/config.toml — add [mcp_servers.playwright] manually');
352
+ }
353
+ }
309
354
  p.log.info('View browser (SSH tunnel):\n' +
310
355
  ` ssh -L 6090:127.0.0.1:6090 ${process.env.USER || 'root'}@<server-ip>\n` +
311
356
  ' Then open: http://localhost:6090/vnc.html');
@@ -394,6 +439,16 @@ export async function runSetup() {
394
439
  sc.stop('Connection failed');
395
440
  p.log.warning('Run manually: claude mcp add playwright -- npx @playwright/mcp@latest --cdp-endpoint "http://localhost:9222"');
396
441
  }
442
+ // Mirror the MCP entry into Codex if it's installed, so the same
443
+ // browser lane works when ai.provider is flipped to codex.
444
+ if (hasCodex) {
445
+ if (addPlaywrightToCodexConfig(cdpUrl)) {
446
+ p.log.success('Codex connected to Chrome (~/.codex/config.toml)');
447
+ }
448
+ else {
449
+ p.log.warning('Could not write ~/.codex/config.toml — add [mcp_servers.playwright] manually');
450
+ }
451
+ }
397
452
  if (vncInstalled) {
398
453
  p.log.info('Watch the browser (localhost only, via SSH tunnel):\n' +
399
454
  ` ssh -L 6090:127.0.0.1:6090 ${process.env.USER || 'root'}@<server-ip>\n` +
package/dist/config.js CHANGED
@@ -24,6 +24,11 @@ const ConfigSchema = z.object({
24
24
  status: z.array(z.string()),
25
25
  reload: z.array(z.string()),
26
26
  }),
27
+ ai: z
28
+ .object({
29
+ provider: z.enum(['claude', 'codex']).default('claude'),
30
+ })
31
+ .default({ provider: 'claude' }),
27
32
  claude: z.object({
28
33
  model: z.string(),
29
34
  personalityFile: z.string(),
@@ -1,5 +1,5 @@
1
1
  import { clearSession, getSessionInfo } from '../ai/sessions.js';
2
- import { reloadSystemPrompt } from '../ai/claude.js';
2
+ import { reloadAllSystemPrompts } from '../ai/providers.js';
3
3
  import { config } from '../config.js';
4
4
  import { runDigestNow } from '../memory/scheduler.js';
5
5
  import { sendText } from '../wa/sender.js';
@@ -41,7 +41,7 @@ export async function tryCommand(ctx) {
41
41
  return true;
42
42
  }
43
43
  if (config.commands.reload.includes(cmd)) {
44
- reloadSystemPrompt();
44
+ reloadAllSystemPrompts();
45
45
  const existed = clearSession(ctx.jid);
46
46
  const reply = existed
47
47
  ? 'Personality reloaded and session reset.'
@@ -1,10 +1,8 @@
1
1
  import { existsSync, readFileSync, readdirSync, writeFileSync, } from 'fs';
2
2
  import { dirname, resolve } from 'path';
3
3
  import { mkdirSync } from 'fs';
4
- import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
5
- import { config } from '../config.js';
4
+ import { getProvider } from '../ai/providers.js';
6
5
  import { logger } from '../logger.js';
7
- import { logPrompt } from '../promptlog.js';
8
6
  import { listJournals, readEntries } from './journals.js';
9
7
  import { memoryRoot, treeRoot, entityIndexPath } from './paths.js';
10
8
  // The compressed view is a rolling index across all memory: people, chats,
@@ -221,42 +219,13 @@ ${raw}
221
219
  Output ONLY the compressed index in the exact format above. No preamble, no explanation, no code fences.`;
222
220
  }
223
221
  async function spawnGenerator(prompt) {
224
- const args = [
225
- '-p',
226
- '--output-format',
227
- 'stream-json',
228
- '--verbose',
229
- '--model',
230
- config.claude.model,
231
- '--permission-mode',
232
- 'acceptEdits',
233
- ];
234
- const { stdout, stderr, durationMs } = await runClaude({
235
- args,
222
+ const { reply } = await getProvider().runTask({
236
223
  input: prompt,
237
- timeoutMs: TIMEOUT_MS.background,
238
224
  caller: 'compressed',
225
+ mode: 'auto',
226
+ lane: 'background',
239
227
  });
240
- const startedAt = Date.now() - durationMs;
241
- const parsed = parseStreamJson(stdout);
242
- if (!parsed) {
243
- throw new Error(`compressed stream-json produced no result event: ${stdout.slice(0, 200)}`);
244
- }
245
- if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
246
- throw new Error(`compressed bad output: ${parsed.result || stdout.slice(0, 200)}`);
247
- }
248
- const output = parsed.result.trim();
249
- void logPrompt({
250
- ts: Math.floor(startedAt / 1000),
251
- caller: 'compressed',
252
- args,
253
- input: prompt,
254
- output,
255
- durationMs,
256
- stderr,
257
- eventTypes: parsed.eventTypes,
258
- });
259
- return output;
228
+ return reply;
260
229
  }
261
230
  let buildInFlight = null;
262
231
  export async function rebuildCompressed() {
@@ -1,51 +1,19 @@
1
- import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
1
+ import { getProvider } from '../ai/providers.js';
2
2
  import { config } from '../config.js';
3
3
  import { logger } from '../logger.js';
4
- import { logPrompt } from '../promptlog.js';
5
4
  import { readLast } from '../store/messages.js';
6
5
  import { markCompressedDirty } from './compressed.js';
7
6
  import { readBrief, readProfile, setLastDigestedAt, writeBrief, writeProfile, } from './store.js';
8
- /**
9
- * Run a stateless Claude call to consolidate memory.
10
- * Returns the new content Claude proposed.
11
- */
7
+ // Stateless agent call to consolidate memory. Routed through the provider
8
+ // abstraction so it runs on whichever CLI is configured.
12
9
  async function spawnDigester(prompt) {
13
- const args = [
14
- '-p',
15
- '--output-format',
16
- 'stream-json',
17
- '--verbose',
18
- '--model',
19
- config.claude.model,
20
- '--permission-mode',
21
- 'acceptEdits',
22
- ];
23
- const { stdout, stderr, durationMs } = await runClaude({
24
- args,
10
+ const { reply } = await getProvider().runTask({
25
11
  input: prompt,
26
- timeoutMs: TIMEOUT_MS.background,
27
- caller: 'digester',
28
- });
29
- const startedAt = Date.now() - durationMs;
30
- const parsed = parseStreamJson(stdout);
31
- if (!parsed) {
32
- throw new Error(`digester stream-json produced no result event: ${stdout.slice(0, 200)}`);
33
- }
34
- if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
35
- throw new Error(`digester bad output: ${parsed.result || stdout.slice(0, 200)}`);
36
- }
37
- const output = parsed.result.trim();
38
- void logPrompt({
39
- ts: Math.floor(startedAt / 1000),
40
12
  caller: 'digester',
41
- args,
42
- input: prompt,
43
- output,
44
- durationMs,
45
- stderr,
46
- eventTypes: parsed.eventTypes,
13
+ mode: 'auto',
14
+ lane: 'background',
47
15
  });
48
- return output;
16
+ return reply;
49
17
  }
50
18
  function formatMessagesForDigest(messages) {
51
19
  return messages
@@ -1,8 +1,7 @@
1
- import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
1
+ import { getProvider } from '../ai/providers.js';
2
2
  import { config } from '../config.js';
3
3
  import { initiate } from '../gateway/outgoing.js';
4
4
  import { logger } from '../logger.js';
5
- import { logPrompt } from '../promptlog.js';
6
5
  import { readLast } from '../store/messages.js';
7
6
  import { canSendProactive } from '../wa/whitelist.js';
8
7
  import { isInQuietHours, nextFireTs, parseCadence, } from './journal-cadence.js';
@@ -15,42 +14,13 @@ function defaultNudgeJid() {
15
14
  return `${config.owner.number}@s.whatsapp.net`;
16
15
  }
17
16
  async function spawnComposer(prompt) {
18
- const args = [
19
- '-p',
20
- '--output-format',
21
- 'stream-json',
22
- '--verbose',
23
- '--model',
24
- config.claude.model,
25
- '--permission-mode',
26
- 'acceptEdits',
27
- ];
28
- const { stdout, stderr, durationMs } = await runClaude({
29
- args,
17
+ const { reply } = await getProvider().runTask({
30
18
  input: prompt,
31
- timeoutMs: TIMEOUT_MS.background,
32
- caller: 'journal-nudger',
33
- });
34
- const startedAt = Date.now() - durationMs;
35
- const parsed = parseStreamJson(stdout);
36
- if (!parsed) {
37
- throw new Error(`nudger stream-json produced no result event: ${stdout.slice(0, 200)}`);
38
- }
39
- if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
40
- throw new Error(`nudger bad output: ${parsed.result || stdout.slice(0, 200)}`);
41
- }
42
- const output = parsed.result.trim();
43
- void logPrompt({
44
- ts: Math.floor(startedAt / 1000),
45
19
  caller: 'journal-nudger',
46
- args,
47
- input: prompt,
48
- output,
49
- durationMs,
50
- stderr,
51
- eventTypes: parsed.eventTypes,
20
+ mode: 'auto',
21
+ lane: 'background',
52
22
  });
53
- return output;
23
+ return reply;
54
24
  }
55
25
  function formatMsg(m) {
56
26
  const who = m.direction === 'out' ? 'assistant' : m.pushName || m.senderNumber || 'user';
@@ -1,52 +1,22 @@
1
- import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
1
+ import { getProvider } from '../ai/providers.js';
2
2
  import { config } from '../config.js';
3
3
  import { logger } from '../logger.js';
4
- import { logPrompt } from '../promptlog.js';
5
4
  import { readLast } from '../store/messages.js';
6
5
  import { appendEntry, getLastScannedTs, getJournal, readEntries, setLastScannedTs, } from './journals.js';
7
6
  // How many recent messages to include in the scan window on each sweep.
8
7
  // Observer runs every memory.sweepIntervalMs (default 3h), so this window
9
8
  // must cover at least that much chat activity to avoid gaps.
10
9
  const SCAN_WINDOW = 200;
11
- // How many recent entries to show Claude for dedup context.
10
+ // How many recent entries to show the observer for dedup context.
12
11
  const DEDUP_WINDOW = 20;
13
12
  async function spawnObserver(prompt) {
14
- const args = [
15
- '-p',
16
- '--output-format',
17
- 'stream-json',
18
- '--verbose',
19
- '--model',
20
- config.claude.model,
21
- '--permission-mode',
22
- 'acceptEdits',
23
- ];
24
- const { stdout, stderr, durationMs } = await runClaude({
25
- args,
13
+ const { reply } = await getProvider().runTask({
26
14
  input: prompt,
27
- timeoutMs: TIMEOUT_MS.background,
28
- caller: 'journal-observer',
29
- });
30
- const startedAt = Date.now() - durationMs;
31
- const parsed = parseStreamJson(stdout);
32
- if (!parsed) {
33
- throw new Error(`journal observer stream-json produced no result event: ${stdout.slice(0, 200)}`);
34
- }
35
- if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
36
- throw new Error(`journal observer bad output: ${parsed.result || stdout.slice(0, 200)}`);
37
- }
38
- const output = parsed.result.trim();
39
- void logPrompt({
40
- ts: Math.floor(startedAt / 1000),
41
15
  caller: 'journal-observer',
42
- args,
43
- input: prompt,
44
- output,
45
- durationMs,
46
- stderr,
47
- eventTypes: parsed.eventTypes,
16
+ mode: 'auto',
17
+ lane: 'background',
48
18
  });
49
- return output;
19
+ return reply;
50
20
  }
51
21
  function formatMsg(m) {
52
22
  const date = new Date(m.timestamp * 1000)
@@ -1,12 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { dirname, resolve } from 'path';
3
- import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
3
+ import { getProvider } from '../ai/providers.js';
4
4
  import { config } from '../config.js';
5
5
  import fastq from 'fastq';
6
6
  import { initiate } from '../gateway/outgoing.js';
7
7
  import { logger } from '../logger.js';
8
- import { logPrompt } from '../promptlog.js';
9
- // Concurrency: how many async Claude workers can run simultaneously.
8
+ // Concurrency: how many async workers can run simultaneously.
10
9
  // Start conservative — each process is expensive (Playwright, multi-minute runs).
11
10
  // Tune via config.asyncTasks.concurrency once we have real usage data.
12
11
  const CONCURRENCY = 3;
@@ -18,7 +17,7 @@ const inProgress = new Map();
18
17
  const queue = fastq.promise(async (task) => {
19
18
  inProgress.set(task.id, task);
20
19
  try {
21
- await runTask(task);
20
+ await executeAsyncTask(task);
22
21
  }
23
22
  catch (err) {
24
23
  logger.error({ err, id: task.id, jid: task.jid }, 'async task failed unexpectedly');
@@ -48,26 +47,6 @@ export function listAsyncTasks(jid) {
48
47
  return all.filter((t) => t.jid === jid);
49
48
  }
50
49
  // ---------- 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
50
  function buildPrompt(task) {
72
51
  const lines = [
73
52
  `You are a BACKGROUND WORKER doing a delayed chat reply. The chat already got an ack ("on it, will report back"). Now you do the work, and your output IS the follow-up chat reply — the full answer the owner is waiting for.`,
@@ -109,66 +88,28 @@ function buildPrompt(task) {
109
88
  ];
110
89
  return lines.join('\n');
111
90
  }
112
- function buildArgs(task) {
113
- const args = [
114
- '-p',
115
- '--output-format',
116
- 'stream-json',
117
- '--verbose',
118
- '--model',
119
- config.claude.model,
120
- '--permission-mode',
121
- 'acceptEdits',
122
- '--append-system-prompt',
123
- systemPrompt(),
91
+ function asyncTaskAddDirs() {
92
+ return [
93
+ ...config.claude.addDirs,
94
+ config.memory.dir,
95
+ config.storage.mediaDir,
124
96
  ];
125
- for (const dir of config.claude.addDirs) {
126
- args.push('--add-dir', resolve(process.cwd(), dir));
127
- }
128
- args.push('--add-dir', resolve(process.cwd(), config.memory.dir));
129
- args.push('--add-dir', resolve(process.cwd(), config.storage.mediaDir));
130
- if (task.allowedTools &&
131
- task.allowedTools !== 'all' &&
132
- task.allowedTools.length > 0) {
133
- args.push('--allowedTools', task.allowedTools.join(','));
134
- }
135
- return args;
136
97
  }
137
- async function spawnClaudeForTask(task, prompt) {
138
- const args = buildArgs(task);
139
- const { stdout, stderr, durationMs } = await runClaude({
140
- args,
141
- input: prompt,
142
- timeoutMs: TIMEOUT_MS.async,
143
- caller: 'async-task',
144
- });
145
- const startedAt = Date.now() - durationMs;
146
- const parsed = parseStreamJson(stdout);
147
- if (!parsed) {
148
- throw new Error(`async task stream-json produced no result event: ${stdout.slice(0, 200)}`);
149
- }
150
- if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
151
- throw new Error(`async task bad output: ${parsed.result || stdout.slice(0, 200)}`);
152
- }
153
- const output = parsed.result.trim();
154
- void logPrompt({
155
- ts: Math.floor(startedAt / 1000),
156
- caller: 'async-task',
157
- args,
158
- input: prompt,
159
- output,
160
- durationMs,
161
- stderr,
162
- eventTypes: parsed.eventTypes,
163
- });
164
- return output;
165
- }
166
- async function runTask(task) {
98
+ async function executeAsyncTask(task) {
167
99
  const prompt = buildPrompt(task);
168
100
  const elapsedLog = () => `${Math.round((Date.now() - task.startedAt * 1000) / 1000)}s`;
169
101
  let output;
170
102
  try {
171
- output = await spawnClaudeForTask(task, prompt);
103
+ const { reply } = await getProvider().runTask({
104
+ input: prompt,
105
+ caller: 'async-task',
106
+ mode: 'auto',
107
+ lane: 'async',
108
+ includeSystemPrompt: true,
109
+ addDirs: asyncTaskAddDirs(),
110
+ allowedTools: task.allowedTools,
111
+ });
112
+ output = reply;
172
113
  }
173
114
  catch (err) {
174
115
  logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'async task claude call failed');
@@ -394,89 +335,44 @@ function buildBrowserPrompt(task, isResume) {
394
335
  ];
395
336
  return lines.join('\n');
396
337
  }
397
- function buildBrowserArgs(task, sessionId) {
398
- const args = [
399
- '-p',
400
- '--output-format',
401
- 'stream-json',
402
- '--verbose',
403
- '--model',
404
- config.claude.model,
405
- '--permission-mode',
406
- 'acceptEdits',
338
+ function browserAddDirs() {
339
+ return [
340
+ ...config.claude.addDirs,
341
+ config.memory.dir,
342
+ config.storage.mediaDir,
407
343
  ];
408
- if (sessionId) {
409
- // Resume — system prompt and memory-dirs are already baked into session
410
- args.push('--resume', sessionId);
411
- }
412
- else {
413
- // First call — bootstrap the persistent session
414
- args.push('--append-system-prompt', systemPrompt());
415
- for (const dir of config.claude.addDirs) {
416
- args.push('--add-dir', resolve(process.cwd(), dir));
417
- }
418
- }
419
- // Memory + media dirs re-added each call (harmless if already baked; needed
420
- // on fresh bootstrap; lets the browser worker Read updated memory files
421
- // between turns).
422
- args.push('--add-dir', resolve(process.cwd(), config.memory.dir));
423
- args.push('--add-dir', resolve(process.cwd(), config.storage.mediaDir));
424
- if (task.allowedTools &&
425
- task.allowedTools !== 'all' &&
426
- task.allowedTools.length > 0) {
427
- args.push('--allowedTools', task.allowedTools.join(','));
428
- }
429
- return args;
430
344
  }
431
345
  async function runBrowserTask(task) {
432
346
  const session = loadBrowserSession();
433
347
  const isResume = !!session.sessionId;
434
348
  const prompt = buildBrowserPrompt(task, isResume);
435
- const args = buildBrowserArgs(task, session.sessionId);
436
- const startedAtMs = Date.now();
437
349
  const elapsedLog = () => `${Math.round((Date.now() - task.startedAt * 1000) / 1000)}s`;
438
- let stdout;
439
- let stderr;
440
- let durationMs;
350
+ let reply;
351
+ let returnedSessionId;
441
352
  try {
442
- const result = await runClaude({
443
- args,
353
+ const result = await getProvider().runTask({
444
354
  input: prompt,
445
- timeoutMs: TIMEOUT_MS.async,
446
355
  caller: 'browser-task',
356
+ mode: 'auto',
357
+ lane: 'async',
358
+ includeSystemPrompt: !isResume,
359
+ addDirs: browserAddDirs(),
360
+ allowedTools: task.allowedTools,
361
+ sessionId: session.sessionId ?? undefined,
447
362
  });
448
- stdout = result.stdout;
449
- stderr = result.stderr;
450
- durationMs = result.durationMs;
363
+ reply = result.reply;
364
+ returnedSessionId = result.sessionId;
451
365
  }
452
366
  catch (err) {
453
- logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'browser task claude call failed');
367
+ logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'browser task provider call failed');
454
368
  await initiate({
455
369
  jid: task.jid,
456
370
  text: `Heads up: the browser task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
457
371
  });
458
372
  return;
459
373
  }
460
- const parsed = parseStreamJson(stdout);
461
- if (!parsed) {
462
- logger.error({ id: task.id }, 'browser task stream-json produced no result event');
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.isError || parsed.subtype !== 'success' || !parsed.result) {
470
- logger.error({ id: task.id, subtype: parsed.subtype, isError: parsed.isError }, '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.sessionId;
374
+ // Persist the session id. On first call the provider returns the new
375
+ // sessionId; on resume it may return the same or a rotated one.
480
376
  if (returnedSessionId) {
481
377
  const now = Math.floor(Date.now() / 1000);
482
378
  saveBrowserSession({
@@ -486,20 +382,9 @@ async function runBrowserTask(task) {
486
382
  resumeCount: (session.resumeCount ?? 0) + (isResume ? 1 : 0),
487
383
  });
488
384
  }
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
- stderr,
498
- eventTypes: parsed.eventTypes,
499
- });
500
385
  // Route markers the same way the general async lane does.
501
386
  const { extractFlags } = await import('../memory/digest-flag.js');
502
- const { clean, digest, journals, journalCreates } = extractFlags(parsed.result);
387
+ const { clean, digest, journals, journalCreates } = extractFlags(reply);
503
388
  const { appendEntry, createJournal, getJournal, isValidSlug } = await import('../memory/journals.js');
504
389
  for (const op of journalCreates) {
505
390
  if (!isValidSlug(op.slug))
@@ -1,4 +1,4 @@
1
- import { askClaude } from '../ai/claude.js';
1
+ import { getProvider } from '../ai/providers.js';
2
2
  import { clearSession, setSession, setUsage } from '../ai/sessions.js';
3
3
  import { config } from '../config.js';
4
4
  import { logger } from '../logger.js';
@@ -14,7 +14,7 @@ function isStaleSessionError(err) {
14
14
  async function callClaude(job) {
15
15
  const startedAt = Date.now();
16
16
  const wasFresh = !job.sessionId;
17
- const { reply, sessionId, usage } = await askClaude({
17
+ const { reply, sessionId, usage } = await getProvider().ask({
18
18
  input: job.input,
19
19
  sessionId: job.sessionId,
20
20
  allowedTools: job.allowedTools,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
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",