@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 +9 -3
- package/dist/estimates/async-task.js +29 -0
- package/dist/estimates/browser-task.js +49 -0
- package/dist/estimates/image-gen.js +7 -0
- package/dist/estimates/index.js +7 -1
- package/dist/estimates/registry.js +4 -1
- package/dist/gateway/outgoing.js +19 -1
- package/dist/queue/browser-queue.js +5 -2
- package/dist/queue/inbound.js +5 -4
- package/dist/queue/worker.js +42 -2
- package/package.json +1 -1
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:
|
|
185
|
-
async:
|
|
186
|
-
background:
|
|
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) {
|
package/dist/estimates/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/gateway/outgoing.js
CHANGED
|
@@ -108,7 +108,25 @@ export async function handleReply(job, result, _originalMsg) {
|
|
|
108
108
|
enqueuePiece({ address, kind: 'text', text: chunkForSend });
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
-
|
|
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
|
-
//
|
|
125
|
-
|
|
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;
|
package/dist/queue/inbound.js
CHANGED
|
@@ -170,10 +170,11 @@ export function markInboundFailed(id, workerId, errorMessage) {
|
|
|
170
170
|
.all();
|
|
171
171
|
return result.length > 0;
|
|
172
172
|
}
|
|
173
|
-
// Orchestrator helper.
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
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;
|
package/dist/queue/worker.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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('-')
|