@c4t4/heyamigo 0.9.18 → 0.9.20

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/spawn.js CHANGED
@@ -180,8 +180,14 @@ export async function runClaude(opts) {
180
180
  }
181
181
  // Per-lane defaults. Individual callers can override, but these are the
182
182
  // shipped caps. Browser-heavy work lives in the async lane.
183
+ //
184
+ // Values picked to accommodate /goal-style long-running tasks (Claude
185
+ // Code / Codex CLI support multi-hour goal sessions). Matching claim
186
+ // TTLs in queue/inbound.ts and queue/browser-queue.ts MUST exceed
187
+ // these — otherwise the orchestrator reclaims live workers and the
188
+ // same task gets processed twice.
183
189
  export const TIMEOUT_MS = {
184
- main: 5 * 60 * 1000,
185
- async: 15 * 60 * 1000,
186
- background: 3 * 60 * 1000,
190
+ main: 30 * 60 * 1000, // 30 min — chat track, covers /goal
191
+ async: 60 * 60 * 1000, // 60 min — async lane, deep browser scrapes
192
+ background: 5 * 60 * 1000, // 5 min — digest / sweep / housekeeping
187
193
  };
@@ -0,0 +1,29 @@
1
+ // Generic async-task estimator (non-browser background work).
2
+ //
3
+ // The general async lane is still on in-memory fastq (no durable
4
+ // table). Real duration samples aren't queryable yet → the estimate
5
+ // uses defaultMs every time until/unless that lane gets migrated to
6
+ // SQLite. Cards still surface useful "long task incoming" UX.
7
+ import { aggregateMean, humanDur, registerEstimator, } from './registry.js';
8
+ class AsyncTaskEstimator {
9
+ kind = 'async-task';
10
+ // 3 min — generic background work tends to be moderate. A deeper
11
+ // research task might run longer; a quick one shorter. Single
12
+ // ballpark until we have real samples.
13
+ defaultMs = 3 * 60 * 1000;
14
+ matches(ctx) {
15
+ return ctx.taskKind === 'async';
16
+ }
17
+ // No durable samples (general async lane is still in-memory fastq).
18
+ // Returning [] forces aggregateMean to fall back to defaultMs.
19
+ querySamples() {
20
+ return [];
21
+ }
22
+ estimate(samples) {
23
+ return aggregateMean(samples, this.defaultMs);
24
+ }
25
+ format(estimate) {
26
+ return `background task, ~${humanDur(estimate.pointMs)}`;
27
+ }
28
+ }
29
+ registerEstimator(new AsyncTaskEstimator());
@@ -0,0 +1,49 @@
1
+ // Generic browser-task estimator. Matches any agent-delegated
2
+ // [ASYNC-BROWSER:] task. Pulls duration samples from the durable
3
+ // browser_tasks table so the average reflects real observed runtimes.
4
+ import { and, desc, eq, isNotNull } from 'drizzle-orm';
5
+ import { getDb } from '../db/index.js';
6
+ import { browserTasks } from '../db/schema.js';
7
+ import { aggregateMean, humanDur, registerEstimator, } from './registry.js';
8
+ class BrowserTaskEstimator {
9
+ kind = 'browser-task';
10
+ // 5 min is a reasonable ballpark for IG/TT scrapes. Real samples
11
+ // dominate after the first 1-2 jobs.
12
+ defaultMs = 5 * 60 * 1000;
13
+ matches(ctx) {
14
+ return ctx.taskKind === 'async-browser';
15
+ }
16
+ querySamples(limit = 20) {
17
+ const db = getDb();
18
+ // All done browser tasks — single bucket. Could be sliced further
19
+ // (per-domain) later via more-specific estimators registered ahead
20
+ // of this catch-all.
21
+ const rows = db
22
+ .select({
23
+ claimedAt: browserTasks.claimedAt,
24
+ updatedAt: browserTasks.updatedAt,
25
+ })
26
+ .from(browserTasks)
27
+ .where(and(eq(browserTasks.status, 'done'), isNotNull(browserTasks.claimedAt)))
28
+ .orderBy(desc(browserTasks.id))
29
+ .limit(limit)
30
+ .all();
31
+ return rows
32
+ .filter((r) => r.claimedAt !== null)
33
+ .map((r) => ({
34
+ durationMs: (r.updatedAt - r.claimedAt) * 1000,
35
+ finishedAt: r.updatedAt,
36
+ }))
37
+ .filter((s) => s.durationMs > 0);
38
+ }
39
+ estimate(samples) {
40
+ return aggregateMean(samples, this.defaultMs);
41
+ }
42
+ format(estimate) {
43
+ if (estimate.rangeMs) {
44
+ return `browser task, ~${humanDur(estimate.rangeMs.lowMs)} to ~${humanDur(estimate.rangeMs.highMs)}`;
45
+ }
46
+ return `browser task, ~${humanDur(estimate.pointMs)}`;
47
+ }
48
+ }
49
+ registerEstimator(new BrowserTaskEstimator());
@@ -15,6 +15,13 @@ class ImageGenEstimator {
15
15
  // observations.
16
16
  defaultMs = 30_000;
17
17
  matches(ctx) {
18
+ // Only match direct user input. When taskKind is set, the context
19
+ // is an agent-delegated task — those go through the browser/async
20
+ // estimators below, not here. Prevents an agent's
21
+ // "[ASYNC-BROWSER: generate marketing image of X]" from being
22
+ // mis-classified as a user-typed image-gen request.
23
+ if (ctx.taskKind)
24
+ return false;
18
25
  return IMAGE_GEN_RE.test(ctx.description);
19
26
  }
20
27
  estimate(samples) {
@@ -6,7 +6,13 @@
6
6
  //
7
7
  // Adding a new kind = drop a file alongside image-gen.ts and import
8
8
  // it below. No other code in the codebase needs to change.
9
+ // Order matters: more-specific estimators register first so they win
10
+ // classify() over the catch-all task estimators. image-gen and other
11
+ // user-input matchers can run first because they explicitly DON'T
12
+ // match when ctx.taskKind is set.
9
13
  import './image-gen.js';
10
- // future: import './browser-ig.js'
14
+ import './browser-task.js'; // catches all [ASYNC-BROWSER:] tasks
15
+ import './async-task.js'; // catches all [ASYNC:] tasks
16
+ // future: import './browser-ig.js' // more specific than browser-task
11
17
  // future: import './voice-gen.js'
12
18
  export { classify, estimate, formatEstimateDefault, humanDur, listEstimators, querySamplesForKind, registerEstimator, } from './registry.js';
@@ -61,7 +61,10 @@ export function estimate(ctx) {
61
61
  const e = classify(ctx);
62
62
  if (!e)
63
63
  return null;
64
- const samples = querySamplesForKind(e.kind);
64
+ // Estimator's own querySamples (if provided) takes precedence —
65
+ // browser/async estimators pull from their dedicated tables. Otherwise
66
+ // fall back to the inbound-by-kind default.
67
+ const samples = e.querySamples ? e.querySamples() : querySamplesForKind(e.kind);
65
68
  const result = e.estimate(samples);
66
69
  const text = (e.format ?? formatEstimateDefault)(result);
67
70
  return { kind: e.kind, result, text };
@@ -108,7 +108,25 @@ export async function handleReply(job, result, _originalMsg) {
108
108
  enqueuePiece({ address, kind: 'text', text: chunkForSend });
109
109
  }
110
110
  }
111
- logger.info({ jid: job.jid, files: files.length, chars: text.length, pieces: pieceIdx }, 'reply enqueued for outbound');
111
+ // Job cards (ETAs for delegated async/browser tasks) go LAST so
112
+ // they arrive after the agent's reply chunks in chat. Each card
113
+ // has its own producer-supplied idempotencyKey; we don't slot them
114
+ // into the piece-numbered key space.
115
+ for (const card of result.jobCards ?? []) {
116
+ enqueueOutbound({
117
+ address,
118
+ kind: 'text',
119
+ text: card.text,
120
+ idempotencyKey: card.idempotencyKey,
121
+ });
122
+ }
123
+ logger.info({
124
+ jid: job.jid,
125
+ files: files.length,
126
+ chars: text.length,
127
+ pieces: pieceIdx,
128
+ cards: result.jobCards?.length ?? 0,
129
+ }, 'reply enqueued for outbound');
112
130
  }
113
131
  // Proactive outbound: send a message to a chat without an incoming
114
132
  // trigger. Same parsing as handleReply; enqueues outbound rows.
@@ -121,8 +121,11 @@ export function markBrowserTaskRetryOrDlq(id, workerId, errorMessage) {
121
121
  return { retried: true, deadLettered: false };
122
122
  });
123
123
  }
124
- // Browser tasks take 1-15 min routinely. Generous reclaim TTL.
125
- const CLAIM_TTL_SECONDS = 20 * 60;
124
+ // MUST exceed TIMEOUT_MS.async (60min as of the /goal-friendly bump)
125
+ // so live browser workers don't get reclaimed mid-spawn. 5min headroom
126
+ // past the spawn cap so the orchestrator only catches truly dead
127
+ // workers. Browser tasks legitimately run 30-45min for deep scrapes.
128
+ const CLAIM_TTL_SECONDS = 65 * 60;
126
129
  export function reclaimStuckBrowserTasks() {
127
130
  const db = getDb();
128
131
  const cutoff = Math.floor(Date.now() / 1000) - CLAIM_TTL_SECONDS;
@@ -170,10 +170,11 @@ export function markInboundFailed(id, workerId, errorMessage) {
170
170
  .all();
171
171
  return result.length > 0;
172
172
  }
173
- // Orchestrator helper. Chat workers run longer than sender workers
174
- // (AI calls + memory writes), so the TTL is more generous. 300s
175
- // matches the typical chat-track timeout (5min).
176
- const CLAIM_TTL_SECONDS = 360;
173
+ // Orchestrator helper. MUST exceed TIMEOUT_MS.main (30min as of the
174
+ // /goal-friendly bump) so live workers don't get reclaimed mid-spawn.
175
+ // 5min headroom past the spawn cap so the orchestrator only catches
176
+ // rows whose worker actually died.
177
+ const CLAIM_TTL_SECONDS = 35 * 60;
177
178
  export function reclaimStuckInbound() {
178
179
  const db = getDb();
179
180
  const cutoff = Math.floor(Date.now() / 1000) - CLAIM_TTL_SECONDS;
@@ -4,6 +4,7 @@ import { config } from '../config.js';
4
4
  import { formatAddress, jidToAddress } from '../db/address.js';
5
5
  import { logger } from '../logger.js';
6
6
  import { addDailyTokens } from '../store/usage.js';
7
+ import { estimate as estimateJob } from '../estimates/index.js';
7
8
  import { extractFlags, filterFlagsByRole } from '../memory/digest-flag.js';
8
9
  import { isValidSlug } from '../memory/journals.js';
9
10
  import { enqueueAsyncTask, enqueueBrowserTask } from './async-tasks.js';
@@ -171,7 +172,15 @@ async function callClaude(job) {
171
172
  // [ASYNC:...] → general lane, stateless, concurrency 3, non-browser work
172
173
  // [ASYNC-BROWSER:...] → browser lane, persistent session, concurrency 1
173
174
  // Both report back via initiate() when done.
174
- for (const t of asyncTasks) {
175
+ //
176
+ // For each delegation, we also build a "job card" — a short ETA
177
+ // message that handleReply will emit after the agent's reply
178
+ // chunks. Gives the user a visible "doing X, ~Y min" instead of
179
+ // wondering whether anything's happening.
180
+ const jobCards = [];
181
+ const cardBase = `card-${job.jid}-${Date.now()}`;
182
+ for (let i = 0; i < asyncTasks.length; i++) {
183
+ const t = asyncTasks[i];
175
184
  enqueueAsyncTask({
176
185
  jid: job.jid,
177
186
  senderNumber: job.senderNumber,
@@ -179,8 +188,19 @@ async function callClaude(job) {
179
188
  originatingMessage: job.text,
180
189
  allowedTools: job.allowedTools ?? 'all',
181
190
  });
191
+ const est = estimateJob({
192
+ description: t.description,
193
+ taskKind: 'async',
194
+ });
195
+ if (est) {
196
+ jobCards.push({
197
+ text: formatJobCard(est.text, t.description),
198
+ idempotencyKey: `${cardBase}-async-${i}`,
199
+ });
200
+ }
182
201
  }
183
- for (const t of asyncBrowserTasks) {
202
+ for (let i = 0; i < asyncBrowserTasks.length; i++) {
203
+ const t = asyncBrowserTasks[i];
184
204
  enqueueBrowserTask({
185
205
  jid: job.jid,
186
206
  senderNumber: job.senderNumber,
@@ -188,6 +208,16 @@ async function callClaude(job) {
188
208
  originatingMessage: job.text,
189
209
  allowedTools: job.allowedTools ?? 'all',
190
210
  });
211
+ const est = estimateJob({
212
+ description: t.description,
213
+ taskKind: 'async-browser',
214
+ });
215
+ if (est) {
216
+ jobCards.push({
217
+ text: formatJobCard(est.text, t.description),
218
+ idempotencyKey: `${cardBase}-browser-${i}`,
219
+ });
220
+ }
191
221
  }
192
222
  // SEND-TEXT: cross-chat text send. Agent specified the destination
193
223
  // address explicitly. Just drops a row in outbound; sender worker
@@ -250,8 +280,18 @@ async function callClaude(job) {
250
280
  journalSlugs: journals.map((j) => j.slug),
251
281
  asyncCount: asyncTasks.length + asyncBrowserTasks.length,
252
282
  },
283
+ jobCards: jobCards.length > 0 ? jobCards : undefined,
253
284
  };
254
285
  }
286
+ // Compact card text. Emoji + ETA + a brief excerpt of what the agent
287
+ // delegated, so the user knows which job each card refers to when
288
+ // multiple are running.
289
+ function formatJobCard(etaText, description) {
290
+ const excerpt = description.length > 100
291
+ ? description.slice(0, 97) + '...'
292
+ : description;
293
+ return `🔄 ${etaText}\n${excerpt}`;
294
+ }
255
295
  function titleCase(slug) {
256
296
  return slug
257
297
  .split('-')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.9.18",
3
+ "version": "0.9.20",
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",