@c4t4/heyamigo 0.9.19 → 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.
@@ -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.
@@ -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.19",
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",