mercury-agent 0.24.3-beta.0 → 0.24.3-beta.2
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/docs/pending-verification.md +13 -1
- package/docs/web-search.md +3 -2
- package/examples/extensions/web-search/brave.ts +56 -7
- package/examples/extensions/web-search/index.ts +57 -17
- package/examples/extensions/web-search/ledger.ts +51 -5
- package/package.json +1 -4
- package/src/adapters/whatsapp-media.ts +152 -52
|
@@ -2286,12 +2286,24 @@ The review of the seven 2026-10-02 fixes landed follow-ups on `dev`. Three baked
|
|
|
2286
2286
|
|
|
2287
2287
|
## Plan unattended-1003 / S6
|
|
2288
2288
|
|
|
2289
|
-
The WhatsApp media download now closes its socket when a bound fires, through a Baileys patch (`patches/`). Host-only, nothing baked: a **sync** (which runs `bun install` because `package.json` and `bun.lock` changed) **and a restart**. No image rebuild.
|
|
2289
|
+
The WhatsApp media download now closes its socket when a bound fires, through a Baileys patch (`patches/`). *(Superseded 2026-10-07: the patch never reached npm-installed hosts, so it was removed and the media `fetch` is now owned by `fetchDecryptedMedia` — `docs/debug/moderate/2026-10-07-baileys-patch-never-reaches-an-npm-installed-host.md`. The greps below for `signal: options.signal` in `node_modules` now print 0 by design.)* Host-only, nothing baked: a **sync** (which runs `bun install` because `package.json` and `bun.lock` changed) **and a restart**. No image rebuild.
|
|
2290
2290
|
|
|
2291
2291
|
- [x] *(Verified 2026-10-05 on the WSL box, dev `6ddb895`, base `mercury-agent:0.24.2-beta.2` = `63637858e06b`, derived `mercury-agent-ext:b2acae430168` (built 13:36:22 IDT, 482 s), restart 13:28:17 IDT: the sync ran `bun install` and the grep prints `1`.)* MANUAL-VERIFY(2026-10-04): After the sync, `grep -c -F "signal: options.signal" ~/mercury-src/node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js` prints 1. A 0 means `bun install` did not run or did not apply the patch, and the fix is not deployed even though the code is.
|
|
2292
2292
|
- [x] *(Verified 2026-10-05 on the WSL box, dev `6ddb895`, base `mercury-agent:0.24.2-beta.2` = `63637858e06b`, derived `mercury-agent-ext:b2acae430168` (built 13:36:22 IDT, 482 s), restart 13:28:17 IDT. Rehearsed, not waited for: the deployed `downloadWhatsAppMedia` from `~/mercury-src`, with production bounds and its patched `node_modules`, against a local raw-TCP server. Open stall: `bound fired while opening` at 30.0 s, `download_failed` to `onSkipped`, server saw the socket close 2 ms later. Body stall after 64 KB: `read bound fired` (idle) at 30.0 s, `download_failed`, socket closed 1 ms later. The live-journal half is still worth a glance the next time a real download stalls.)* MANUAL-VERIFY(2026-10-04): The next time the journal shows `WhatsApp media read bound fired` or `bound fired while opening`, `ss -tnp | grep -c <bun pid>` taken a few seconds later shows no connection to the WhatsApp media host left open for that download, and the user was told the file could not be downloaded.
|
|
2293
2293
|
- [x] *(Verified 2026-10-05: `docker build -f Dockerfile.host` from `~/mercury-src` at `6ddb895` exited 0, `COPY patches/` ran before `bun install --production`, and the built image's `node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js` has `signal: options.signal` (1). Throwaway tag removed.)* MANUAL-VERIFY(2026-10-04): If any box builds `Dockerfile.host`, its next build passes the `bun install --production` step (it now copies `patches/` first; without it the install fails with `Couldn't find patch file`).
|
|
2294
2294
|
|
|
2295
|
+
## baileys-patch-never-reaches-an-npm-installed-host (2026-10-07)
|
|
2296
|
+
|
|
2297
|
+
The media download fetches and decrypts itself instead of through a patched Baileys. Host-only: a sync (`bun install`, since `package.json` and `bun.lock` changed) and a restart; no image rebuild.
|
|
2298
|
+
|
|
2299
|
+
- [ ] MANUAL-VERIFY(2026-10-07): After deploying, send the bot a photo, a voice note and a PDF in a group it answers in, and confirm it describes each one — real WhatsApp media must decrypt through the new path, which the tests only exercise with media they encrypted themselves.
|
|
2300
|
+
|
|
2301
|
+
## spent-brave-budget-leaves-no-web-search (2026-10-08)
|
|
2302
|
+
|
|
2303
|
+
Host-side only (`examples/extensions/web-search`): a sync and a restart; no image rebuild.
|
|
2304
|
+
|
|
2305
|
+
- [ ] MANUAL-VERIFY(2026-10-08): On the VPS, once October's Brave budget (600) is spent, ask the bot a keyword question; it should answer by searching in the browser rather than saying it cannot search.
|
|
2306
|
+
|
|
2295
2307
|
## Plan unattended-1003 / S7
|
|
2296
2308
|
|
|
2297
2309
|
`pinchtab_nav` and the session injector now ship as files in the pinchtab extension's `bin/`. They reach the box through the installed snapshot, which is refreshed on restart, and from there into the **derived** image, whose hash includes the bin files. So this is a restart, then wait for the journal's `Built derived agent image` line. No base rebuild.
|
package/docs/web-search.md
CHANGED
|
@@ -12,12 +12,13 @@ Or install from the dashboard's **Features** page (one click).
|
|
|
12
12
|
|
|
13
13
|
No API key is required for the browser. The extension uses [pinchtab](https://www.npmjs.com/package/pinchtab) to control a local headless Chromium and opens pages the same way a human would. It states no search policy: it does not tell the agent to search through a search engine's own site, because a scraped `search.brave.com` is unmetered and ends in a "Verifying you're not a bot" page after a few queries.
|
|
14
14
|
|
|
15
|
-
The separate `web-search` extension (`mercury add web-search`) adds structured site search (flights, hotels, cars, apartments) on top of pinchtab and, for fast keyword and news lookups, exposes the Brave Search **API** as a host-side capability: set `MERCURY_BRAVE_API_KEY` in `.env` and the agent can run `mrctl capability web-search news '{"q":"..."}'` (or `web`). While `web-search` is loaded **and the host has a key**, every run's system prompt says keyword search goes only through that capability, never through a search engine's site in the browser, and its
|
|
15
|
+
The separate `web-search` extension (`mercury add web-search`) adds structured site search (flights, hotels, cars, apartments) on top of pinchtab and, for fast keyword and news lookups, exposes the Brave Search **API** as a host-side capability: set `MERCURY_BRAVE_API_KEY` in `.env` and the agent can run `mrctl capability web-search news '{"q":"..."}'` (or `web`). While `web-search` is loaded **and the host has a key**, every run's system prompt says keyword search goes only through that capability, never through a search engine's site in the browser, and its run-cap, hourly-cap and broken-key (401/403) refusals say the same instead of pointing at the browser. Once the month's credit is gone (the monthly budget below is spent, or Brave answers 402), that rule is replaced: the prompt and the refusal tell the agent to search in the browser until the month resets, because a box with a key and no credit would otherwise have no keyword search at all. Without a key the capability answers every call with a 503, so that rule is not emitted: a keyless host states no search policy, like a host without `web-search`. The key is declared `hostOnly`, so it never enters a container; the host makes the call, applies a per-space hourly cap (`brave_hourly_cap`, default 60), and returns trimmed results.
|
|
16
16
|
|
|
17
17
|
Three more brakes sit behind it:
|
|
18
18
|
- **Per run:** `run_cap`, default 20 billed calls. The same query (same `q`, `count` and `freshness`) asked twice in one run is answered once, including two copies sent at the same time and a repeat after the run was refused. A cap set to a number too large to be exact (past 2^53 - 1) is rejected and falls back to the default.
|
|
19
19
|
- **Per host per calendar month (UTC):** `monthly_budget`, default 1000, about Brave's free grant. It is read from the `@global` scope only, so a space cannot change it. It counts attempts, so calls that time out or fail upstream count too.
|
|
20
|
-
- **Monthly warnings:** the host's operators (`admins`) get one message at 80%, one at 95%, and one at 100%, when
|
|
20
|
+
- **Monthly warnings:** the host's operators (`admins`) get one message at 80%, one at 95%, and one at 100%, when Brave search stops on that host and the bot searches in the browser until the next month.
|
|
21
|
+
- **Brave's own 402:** prepaid credit can run out before this host's count does (another bot on the key, or usage from before the count existed). A 402 marks the month spent at the current `monthly_budget` and sends the 100% message in Brave's terms. Raising `monthly_budget` after topping up brings Brave back the same month.
|
|
21
22
|
|
|
22
23
|
Each host counts only its own calls, so when two bots share one key, set each host's `monthly_budget` to its share. The query log line carries `monthUsed` and, when Brave's response headers report it, `braveMonthlyRemaining`. That figure is the account total across every bot on the key.
|
|
23
24
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type LedgerDecision,
|
|
21
21
|
type LedgerThreshold,
|
|
22
22
|
ledgerMonth,
|
|
23
|
+
markMonthExhausted,
|
|
23
24
|
parseBraveQuota,
|
|
24
25
|
reserveBilledCall,
|
|
25
26
|
} from "./ledger.js";
|
|
@@ -83,6 +84,18 @@ export const BRAVE_PROVIDER = "brave";
|
|
|
83
84
|
export const NO_SCRAPED_SEARCH =
|
|
84
85
|
"Answer from the results you already have. Do not run the search on a search engine's own site in the browser (search.brave.com, Google, Bing, DuckDuckGo). You may still open pages those results link to, and the specialist sites the web-search skill names.";
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* What the agent is told once the month's credit is gone — our budget spent,
|
|
89
|
+
* or Brave's 402. The ban above protects the metered path from a runaway loop
|
|
90
|
+
* (run cap, hourly cap) or a broken key (401/403); with no credit left there
|
|
91
|
+
* is no metered path to protect, and keeping the ban left a box *with* a key
|
|
92
|
+
* worse off than one without (bug spent-brave-budget-leaves-no-web-search).
|
|
93
|
+
* Until the month resets, search goes to the browser, as on a keyless box.
|
|
94
|
+
* The prompt (index.ts) and both refusals carry this one text.
|
|
95
|
+
*/
|
|
96
|
+
export const SEARCH_IN_BROWSER =
|
|
97
|
+
"This month's web-search credit is used up, so do not call web-search again this month: search in the browser instead (pinchtab), e.g. on DuckDuckGo or Google, then open the pages the results link to. The specialist sites the web-search skill names still apply.";
|
|
98
|
+
|
|
86
99
|
/** Brave freshness codes: past day / week / month / year. */
|
|
87
100
|
const FRESHNESS = new Set(["pd", "pw", "pm", "py"]);
|
|
88
101
|
const MAX_QUERY_CHARS = 400;
|
|
@@ -523,7 +536,7 @@ export async function handleBraveCapability(
|
|
|
523
536
|
run.stop = {
|
|
524
537
|
status: 429,
|
|
525
538
|
data: {
|
|
526
|
-
error: `This host's Brave search budget for ${ledgerMonth(nowMs)} (${deps.monthlyBudget} searches) is spent.
|
|
539
|
+
error: `This host's Brave search budget for ${ledgerMonth(nowMs)} (${deps.monthlyBudget} searches) is spent. ${SEARCH_IN_BROWSER}`,
|
|
527
540
|
},
|
|
528
541
|
};
|
|
529
542
|
return run.stop;
|
|
@@ -619,14 +632,45 @@ async function callBrave(
|
|
|
619
632
|
permanent,
|
|
620
633
|
detail,
|
|
621
634
|
});
|
|
635
|
+
// 402 is Brave saying the prepaid credit is gone: the month is spent
|
|
636
|
+
// whatever our count says, for every later run too (the prompt reads
|
|
637
|
+
// the same row), until the month resets.
|
|
638
|
+
const creditGone = res.status === 402;
|
|
639
|
+
if (creditGone) {
|
|
640
|
+
const at = (deps.now ?? Date.now)();
|
|
641
|
+
// Operators hear it now, not on a later call: the prompt stops sending
|
|
642
|
+
// the agent here, so there may never be one. Once per budget, even if
|
|
643
|
+
// 100% was announced earlier in budget terms. A failed ledger write
|
|
644
|
+
// must not hide the 402 from the agent, so it is logged and passed.
|
|
645
|
+
try {
|
|
646
|
+
const mark = markMonthExhausted(
|
|
647
|
+
deps.store,
|
|
648
|
+
BRAVE_PROVIDER,
|
|
649
|
+
deps.monthlyBudget,
|
|
650
|
+
at,
|
|
651
|
+
);
|
|
652
|
+
if (mark.newlyMarked) {
|
|
653
|
+
announceBudget(deps, 100, mark.used, at, "provider-402");
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
deps.log?.warn(
|
|
657
|
+
"web-search: could not record Brave's 402 in the ledger",
|
|
658
|
+
{
|
|
659
|
+
error: error instanceof Error ? error.message : String(error),
|
|
660
|
+
},
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
622
664
|
// Same rule as the cap branch: mrctl shows only the error string, so
|
|
623
665
|
// the "stop retrying" instruction has to live inside it.
|
|
624
666
|
const failed: CapabilityResult = {
|
|
625
667
|
status: 502,
|
|
626
668
|
data: {
|
|
627
|
-
error:
|
|
628
|
-
? `Brave search is
|
|
629
|
-
:
|
|
669
|
+
error: creditGone
|
|
670
|
+
? `Brave search credit is used up (Brave returned 402: ${detail}). ${SEARCH_IN_BROWSER}`
|
|
671
|
+
: permanent
|
|
672
|
+
? `Brave search is unavailable this run (Brave returned ${res.status}: ${detail}). Do not retry web-search. ${NO_SCRAPED_SEARCH}`
|
|
673
|
+
: `Brave returned ${res.status}: ${detail}`,
|
|
630
674
|
},
|
|
631
675
|
};
|
|
632
676
|
// A permanent status will not clear this run: every later call gets it
|
|
@@ -685,6 +729,7 @@ function announceBudget(
|
|
|
685
729
|
threshold: LedgerThreshold,
|
|
686
730
|
used: number,
|
|
687
731
|
now: number,
|
|
732
|
+
cause: "budget" | "provider-402" = "budget",
|
|
688
733
|
): void {
|
|
689
734
|
const month = ledgerMonth(now);
|
|
690
735
|
deps.log?.warn("web-search: Brave monthly budget threshold", {
|
|
@@ -692,11 +737,15 @@ function announceBudget(
|
|
|
692
737
|
used,
|
|
693
738
|
budget: deps.monthlyBudget,
|
|
694
739
|
month,
|
|
740
|
+
cause,
|
|
695
741
|
});
|
|
696
|
-
const head =
|
|
742
|
+
const head =
|
|
743
|
+
cause === "provider-402"
|
|
744
|
+
? `Brave search on this host: Brave answered 402 (prepaid credit used up) in ${month}, after ${used} billed searches counted here.`
|
|
745
|
+
: `Brave search on this host: ${used} of ${deps.monthlyBudget} billed searches used in ${month} (${threshold}% of web-search.monthly_budget).`;
|
|
697
746
|
deps.alertOperators(
|
|
698
747
|
threshold === 100
|
|
699
|
-
? `${head}
|
|
700
|
-
: `${head} At 100% web search
|
|
748
|
+
? `${head} Until next month the bot searches in the browser instead (scraped, may hit CAPTCHAs). After topping up the Brave account, raise web-search.monthly_budget in the @global scope to bring Brave back.`
|
|
749
|
+
: `${head} At 100% web search moves to the browser on this host until next month.`,
|
|
701
750
|
);
|
|
702
751
|
}
|
|
@@ -5,13 +5,16 @@ import type {
|
|
|
5
5
|
} from "../../../src/extensions/types.js";
|
|
6
6
|
import {
|
|
7
7
|
BRAVE_ENV,
|
|
8
|
+
BRAVE_PROVIDER,
|
|
8
9
|
braveApiKey,
|
|
9
10
|
DEFAULT_HOURLY_CAP,
|
|
10
11
|
DEFAULT_MONTHLY_BUDGET,
|
|
11
12
|
DEFAULT_RUN_CAP,
|
|
12
13
|
handleBraveCapability,
|
|
13
14
|
RunBudgets,
|
|
15
|
+
SEARCH_IN_BROWSER,
|
|
14
16
|
} from "./brave.js";
|
|
17
|
+
import { isMonthSpent } from "./ledger.js";
|
|
15
18
|
|
|
16
19
|
// `mercury.config()` takes the bare key and namespaces it (config-registry.ts:
|
|
17
20
|
// `${extension}.${key}`); every reader must therefore ask for the namespaced
|
|
@@ -85,19 +88,39 @@ export const SEARCH_ROUTING_PROMPT =
|
|
|
85
88
|
"AutoScout24, Airbnb). " +
|
|
86
89
|
PRICE_ROUTING_PROMPT;
|
|
87
90
|
|
|
91
|
+
/** The rule while the month's Brave credit is gone: the browser searches. */
|
|
92
|
+
export const CREDIT_GONE_ROUTING_PROMPT = `Web search: ${SEARCH_IN_BROWSER} ${PRICE_ROUTING_PROMPT}`;
|
|
93
|
+
|
|
88
94
|
/**
|
|
89
|
-
* The fragment a run gets, given the host's Brave key
|
|
90
|
-
*
|
|
91
|
-
* capability
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* (
|
|
95
|
+
* The fragment a run gets, given the host's Brave key and whether this month's
|
|
96
|
+
* credit is gone. The rule forbids every keyword-search path but the
|
|
97
|
+
* capability, so it is only true while the capability can answer:
|
|
98
|
+
* - no key: every call is a 503, so this extension states no search policy,
|
|
99
|
+
* exactly like a box without web-search; the skill and its 503 remain.
|
|
100
|
+
* - credit gone (budget spent, or Brave's 402): every call is refused until
|
|
101
|
+
* the month resets, so the rule is replaced by "search in the browser" —
|
|
102
|
+
* keeping it left a box with a key worse off than one without.
|
|
95
103
|
* The price pointer is not search policy — Zap needs no key — so it stays.
|
|
96
104
|
*/
|
|
97
|
-
export function searchRoutingPrompt(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
export function searchRoutingPrompt(
|
|
106
|
+
rawKey: string | undefined,
|
|
107
|
+
creditGone = false,
|
|
108
|
+
): string {
|
|
109
|
+
if (braveApiKey(rawKey) === "") return PRICE_ROUTING_PROMPT;
|
|
110
|
+
return creditGone ? CREDIT_GONE_ROUTING_PROMPT : SEARCH_ROUTING_PROMPT;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The host's monthly budget: @global only, so a space cannot raise it. The
|
|
115
|
+
* capability and the per-run prompt both read it here, so they cannot drift.
|
|
116
|
+
*/
|
|
117
|
+
function readMonthlyBudget(
|
|
118
|
+
ctx: Pick<MercuryExtensionContext, "getConfig">,
|
|
119
|
+
): number {
|
|
120
|
+
return readCap(
|
|
121
|
+
ctx.getConfig(GLOBAL_SCOPE, MONTHLY_BUDGET_CONFIG_FULL_KEY),
|
|
122
|
+
DEFAULT_MONTHLY_BUDGET,
|
|
123
|
+
);
|
|
101
124
|
}
|
|
102
125
|
|
|
103
126
|
export default function (mercury: MercuryExtensionAPI) {
|
|
@@ -126,7 +149,7 @@ export default function (mercury: MercuryExtensionAPI) {
|
|
|
126
149
|
|
|
127
150
|
mercury.config(MONTHLY_BUDGET_CONFIG_KEY, {
|
|
128
151
|
description:
|
|
129
|
-
"Max billed Brave API calls this host makes per calendar month (UTC), across all spaces. Read from the global scope only. Warns operators at 80% and 95
|
|
152
|
+
"Max billed Brave API calls this host makes per calendar month (UTC), across all spaces. Read from the global scope only. Warns operators at 80% and 95%; at 100% Brave is refused and the bot searches in the browser until next month. 0 blocks every Brave call (browser search from the start).",
|
|
130
153
|
default: String(DEFAULT_MONTHLY_BUDGET),
|
|
131
154
|
validate: isValidCap,
|
|
132
155
|
});
|
|
@@ -152,10 +175,7 @@ export default function (mercury: MercuryExtensionAPI) {
|
|
|
152
175
|
DEFAULT_RUN_CAP,
|
|
153
176
|
),
|
|
154
177
|
runs,
|
|
155
|
-
monthlyBudget:
|
|
156
|
-
ctx.getConfig(GLOBAL_SCOPE, MONTHLY_BUDGET_CONFIG_FULL_KEY),
|
|
157
|
-
DEFAULT_MONTHLY_BUDGET,
|
|
158
|
-
),
|
|
178
|
+
monthlyBudget: readMonthlyBudget(ctx),
|
|
159
179
|
alertOperators: (text) =>
|
|
160
180
|
ctx.alertOperators({ extension: "web-search", text }),
|
|
161
181
|
log: ctx.log,
|
|
@@ -168,7 +188,27 @@ export default function (mercury: MercuryExtensionAPI) {
|
|
|
168
188
|
// per run, from the same env the capability reads, so the prompt and the
|
|
169
189
|
// capability's 503 agree. The browser extension's fragment states no search
|
|
170
190
|
// policy at all.
|
|
171
|
-
|
|
172
|
-
|
|
191
|
+
// Credit is read from the same ledger row and the same global budget the
|
|
192
|
+
// capability reads, so the prompt never sends the agent to a refusal.
|
|
193
|
+
mercury.on("before_container", async (_event, ctx) => {
|
|
194
|
+
// A hook that throws blocks the run (src/extensions/hooks.ts), so a ledger
|
|
195
|
+
// read failure must not cost every run on the host its turn: it falls
|
|
196
|
+
// back to "credit left", the rule before this check existed, and says so.
|
|
197
|
+
let creditGone = false;
|
|
198
|
+
try {
|
|
199
|
+
creditGone = isMonthSpent(
|
|
200
|
+
mercury.store,
|
|
201
|
+
BRAVE_PROVIDER,
|
|
202
|
+
readMonthlyBudget(ctx),
|
|
203
|
+
Date.now(),
|
|
204
|
+
);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
ctx.log?.warn("web-search: ledger read failed; assuming credit left", {
|
|
207
|
+
error: error instanceof Error ? error.message : String(error),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
systemPrompt: searchRoutingPrompt(process.env[BRAVE_ENV], creditGone),
|
|
212
|
+
};
|
|
173
213
|
});
|
|
174
214
|
}
|
|
@@ -34,6 +34,15 @@ interface LedgerRow {
|
|
|
34
34
|
count: number;
|
|
35
35
|
/** Thresholds already announced this month. */
|
|
36
36
|
announced: number[];
|
|
37
|
+
/**
|
|
38
|
+
* The provider itself said this month's credit is gone (Brave's 402), which
|
|
39
|
+
* can come before our count does — another bot on the same key, or usage
|
|
40
|
+
* from before the ledger existed. Lives in the month's row, so it clears on
|
|
41
|
+
* the 1st with everything else. Holds only while the budget is the one it
|
|
42
|
+
* was recorded under: the 100% alert tells the operator to top up and raise
|
|
43
|
+
* `monthly_budget`, and doing that must bring search back this month.
|
|
44
|
+
*/
|
|
45
|
+
exhaustedAtBudget?: number;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/**
|
|
@@ -53,6 +62,9 @@ function readRow(
|
|
|
53
62
|
return {
|
|
54
63
|
count: p.count,
|
|
55
64
|
announced: p.announced.filter((n) => typeof n === "number"),
|
|
65
|
+
...(typeof p.exhaustedAtBudget === "number"
|
|
66
|
+
? { exhaustedAtBudget: p.exhaustedAtBudget }
|
|
67
|
+
: {}),
|
|
56
68
|
corrupt: false,
|
|
57
69
|
};
|
|
58
70
|
}
|
|
@@ -63,9 +75,11 @@ function readRow(
|
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
/**
|
|
66
|
-
* Read-only: is this month's
|
|
67
|
-
*
|
|
68
|
-
* reported as itself rather than as
|
|
78
|
+
* Read-only: is this month's credit gone — our budget spent, or the provider
|
|
79
|
+
* already said so? For checks that must run before something else is written
|
|
80
|
+
* (the hourly window), so a spent month is reported as itself rather than as
|
|
81
|
+
* whichever limit happened to come first. The per-run prompt asks the same
|
|
82
|
+
* question, so what the agent is told and what the capability does agree.
|
|
69
83
|
*/
|
|
70
84
|
export function isMonthSpent(
|
|
71
85
|
store: ExtensionStore,
|
|
@@ -73,7 +87,39 @@ export function isMonthSpent(
|
|
|
73
87
|
budget: number,
|
|
74
88
|
now: number,
|
|
75
89
|
): boolean {
|
|
76
|
-
|
|
90
|
+
const row = readRow(store, ledgerKey(provider, ledgerMonth(now)));
|
|
91
|
+
return row.exhaustedAtBudget === budget || row.count >= budget;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Record that the provider refused for lack of credit (402) this month, under
|
|
96
|
+
* the current `budget` (see `exhaustedAtBudget`). Every threshold counts as
|
|
97
|
+
* announced, so a later refusal does not announce one again in budget terms. Returns
|
|
98
|
+
* whether this is news (not already recorded under this budget) and the count,
|
|
99
|
+
* so the caller announces it exactly once per budget.
|
|
100
|
+
*/
|
|
101
|
+
export function markMonthExhausted(
|
|
102
|
+
store: ExtensionStore,
|
|
103
|
+
provider: string,
|
|
104
|
+
budget: number,
|
|
105
|
+
now: number,
|
|
106
|
+
): { newlyMarked: boolean; used: number } {
|
|
107
|
+
const key = ledgerKey(provider, ledgerMonth(now));
|
|
108
|
+
const { corrupt: _corrupt, ...row } = readRow(store, key);
|
|
109
|
+
if (row.exhaustedAtBudget === budget) {
|
|
110
|
+
return { newlyMarked: false, used: row.count };
|
|
111
|
+
}
|
|
112
|
+
// Every threshold: the 402 is the 100% message, and a later refusal must
|
|
113
|
+
// not then announce an 80% or 95% the month has already passed.
|
|
114
|
+
const announced = [
|
|
115
|
+
...row.announced,
|
|
116
|
+
...LEDGER_THRESHOLDS.filter((t) => !row.announced.includes(t)),
|
|
117
|
+
];
|
|
118
|
+
store.set(
|
|
119
|
+
key,
|
|
120
|
+
JSON.stringify({ ...row, announced, exhaustedAtBudget: budget }),
|
|
121
|
+
);
|
|
122
|
+
return { newlyMarked: true, used: row.count };
|
|
77
123
|
}
|
|
78
124
|
|
|
79
125
|
export interface LedgerDecision {
|
|
@@ -104,7 +150,7 @@ export function reserveBilledCall(
|
|
|
104
150
|
): LedgerDecision {
|
|
105
151
|
const key = ledgerKey(provider, ledgerMonth(now));
|
|
106
152
|
const { corrupt, ...row } = readRow(store, key);
|
|
107
|
-
const allowed = row.count < budget;
|
|
153
|
+
const allowed = row.exhaustedAtBudget !== budget && row.count < budget;
|
|
108
154
|
if (allowed) row.count += 1;
|
|
109
155
|
const pct = budget > 0 ? (row.count / budget) * 100 : 100;
|
|
110
156
|
// At the refusal the month is spent whatever the count says (budget 0, or a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercury-agent",
|
|
3
|
-
"version": "0.24.3-beta.
|
|
3
|
+
"version": "0.24.3-beta.2",
|
|
4
4
|
"description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Avishai Tsabari",
|
|
@@ -151,8 +151,5 @@
|
|
|
151
151
|
"ws": ">=8.20.1",
|
|
152
152
|
"qs": ">=6.15.2",
|
|
153
153
|
"lodash": ">=4.18.1"
|
|
154
|
-
},
|
|
155
|
-
"patchedDependencies": {
|
|
156
|
-
"@whiskeysockets/baileys@7.0.0-rc14": "patches/@whiskeysockets%2Fbaileys@7.0.0-rc14.patch"
|
|
157
154
|
}
|
|
158
155
|
}
|
|
@@ -6,10 +6,17 @@
|
|
|
6
6
|
* can be reused by other adapters.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createDecipheriv } from "node:crypto";
|
|
9
10
|
import path from "node:path";
|
|
10
|
-
import
|
|
11
|
+
import { Readable, Transform } from "node:stream";
|
|
12
|
+
import type { ReadableStream as WebReadableStream } from "node:stream/web";
|
|
11
13
|
import {
|
|
12
|
-
|
|
14
|
+
type MediaType as BaileysMediaType,
|
|
15
|
+
DEFAULT_ORIGIN,
|
|
16
|
+
extractMessageContent,
|
|
17
|
+
getContentType,
|
|
18
|
+
getMediaKeys,
|
|
19
|
+
getUrlFromDirectPath,
|
|
13
20
|
normalizeMessageContent,
|
|
14
21
|
type proto,
|
|
15
22
|
type WAMessage,
|
|
@@ -35,26 +42,6 @@ import type {
|
|
|
35
42
|
MessageAttachment,
|
|
36
43
|
} from "../types.js";
|
|
37
44
|
|
|
38
|
-
const silentBaileysLogger: {
|
|
39
|
-
level: string;
|
|
40
|
-
child: () => typeof silentBaileysLogger;
|
|
41
|
-
trace: () => void;
|
|
42
|
-
debug: () => void;
|
|
43
|
-
info: () => void;
|
|
44
|
-
warn: () => void;
|
|
45
|
-
error: () => void;
|
|
46
|
-
fatal: () => void;
|
|
47
|
-
} = {
|
|
48
|
-
level: "silent",
|
|
49
|
-
child: () => silentBaileysLogger,
|
|
50
|
-
trace: () => undefined,
|
|
51
|
-
debug: () => undefined,
|
|
52
|
-
info: () => undefined,
|
|
53
|
-
warn: () => undefined,
|
|
54
|
-
error: () => undefined,
|
|
55
|
-
fatal: () => undefined,
|
|
56
|
-
};
|
|
57
|
-
|
|
58
45
|
/**
|
|
59
46
|
* Media info extracted from a WhatsApp message.
|
|
60
47
|
* Used internally before download.
|
|
@@ -81,8 +68,8 @@ export interface MediaDownloadOptions {
|
|
|
81
68
|
*/
|
|
82
69
|
onSkipped?: (info: DroppedAttachment) => void;
|
|
83
70
|
/**
|
|
84
|
-
* The byte stream for a message. Default:
|
|
85
|
-
*
|
|
71
|
+
* The byte stream for a message. Default: `fetchDecryptedMedia` — our
|
|
72
|
+
* `fetch` of the media URL, decrypted with Baileys' media keys. Injected only so the write legs below
|
|
86
73
|
* can be exercised without a socket; production passes nothing.
|
|
87
74
|
*/
|
|
88
75
|
download?: (
|
|
@@ -100,18 +87,23 @@ export interface MediaDownloadOptions {
|
|
|
100
87
|
export interface MediaDownloadContext {
|
|
101
88
|
/** Aborted on every exit but a clean end; this is what closes the socket. */
|
|
102
89
|
signal: AbortSignal;
|
|
103
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* The phone re-upload, for an injected download that asks for one (production
|
|
92
|
+
* never does; see `WHATSAPP_REUPLOAD_BOUND_MS`), under the re-upload bound.
|
|
93
|
+
*/
|
|
104
94
|
reuploadRequest: (msg: WAMessage) => Promise<WAMessage>;
|
|
105
95
|
}
|
|
106
96
|
|
|
107
97
|
// The time bounds (`MediaReadBounds`, `MEDIA_READ_BOUNDS`) live in
|
|
108
98
|
// `core/media.ts`, shared with the URL legs so the two readers cannot drift.
|
|
109
|
-
//
|
|
99
|
+
// The media `fetch` carries no timeout, so without them a stalled connection
|
|
110
100
|
// holds the turn forever; here the idle bound is also what ends a body whose
|
|
111
101
|
// connection died mid-download, because `pipe()` never tells the decrypt
|
|
112
102
|
// Transform that its source errored (see `readStreamCapped`). A fired bound
|
|
113
|
-
// closes the socket only through the request's `AbortSignal`, which
|
|
114
|
-
//
|
|
103
|
+
// closes the socket only through the request's `AbortSignal`, which is why
|
|
104
|
+
// `fetchDecryptedMedia` owns the `fetch`: Baileys' `getHttpStream` takes no
|
|
105
|
+
// signal, and the dependency patch that once threaded one in never reached an
|
|
106
|
+
// npm-installed host (`patchedDependencies` applies to the root project only).
|
|
115
107
|
|
|
116
108
|
/**
|
|
117
109
|
* The bound on the phone re-upload Baileys requests when a media URL has
|
|
@@ -124,10 +116,12 @@ export interface MediaDownloadContext {
|
|
|
124
116
|
* plain server round trip. Past it the turn reports `download_failed`, which
|
|
125
117
|
* is the right answer for a phone that is offline.
|
|
126
118
|
*
|
|
127
|
-
* Unreachable
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
119
|
+
* Unreachable in production: `fetchDecryptedMedia` never requests a
|
|
120
|
+
* re-upload, which keeps the behaviour Baileys 7.0.0-rc14 had (it asked only
|
|
121
|
+
* when `error.status` was 404/410, and its own fetch error carried the code in
|
|
122
|
+
* `output.statusCode`, so never). An expired URL is `download_failed`.
|
|
123
|
+
* Making it live is a behaviour change that needs a real old quoted message
|
|
124
|
+
* to verify against; `tests/whatsapp-media-socket-close.test.ts` pins the 410.
|
|
131
125
|
*/
|
|
132
126
|
export const WHATSAPP_REUPLOAD_BOUND_MS = 2 * 60_000;
|
|
133
127
|
|
|
@@ -173,8 +167,8 @@ function destroyWithSource(stream: Readable): void {
|
|
|
173
167
|
* The returned promise is itself the race: whichever of end, error, the cap or
|
|
174
168
|
* a bound comes first settles it, and every exit clears both timers.
|
|
175
169
|
*
|
|
176
|
-
*
|
|
177
|
-
* into. Destroying the Transform alone only unpipes the body, and Bun's fetch
|
|
170
|
+
* The download's stream is a decrypt `Transform` that the HTTP body is
|
|
171
|
+
* `pipe`d into (`fetchDecryptedMedia`, the shape Baileys' own download had). Destroying the Transform alone only unpipes the body, and Bun's fetch
|
|
178
172
|
* keeps buffering a body nobody reads (measured 2026-10-02: 591 MB RSS for a
|
|
179
173
|
* 512 MB body, against 54 MB when the source is destroyed too). The source is
|
|
180
174
|
* not exposed, so it is taken from the `unpipe` event every exit triggers:
|
|
@@ -188,13 +182,13 @@ function destroyWithSource(stream: Readable): void {
|
|
|
188
182
|
* - **No early listener.** The destination of a `pipe()` holds no reference
|
|
189
183
|
* to its source (the closures in `pipe` do), so the source cannot be
|
|
190
184
|
* reached until it unpipes. That is safe only because the listener below is
|
|
191
|
-
* attached synchronously on entry, and
|
|
192
|
-
* `
|
|
185
|
+
* attached synchronously on entry, and `fetchDecryptedMedia`'s path from
|
|
186
|
+
* `body.pipe(decrypt)` to our caller is promise resolutions only: `pipe`
|
|
193
187
|
* starts the flow with `process.nextTick`, which runs after the microtask
|
|
194
188
|
* queue drains, so no chunk and no decrypt error can reach the Transform
|
|
195
189
|
* before this listener exists — even with the body already buffered. A
|
|
196
|
-
* macrotask inserted between
|
|
197
|
-
*
|
|
190
|
+
* macrotask inserted between that `pipe` and its `return` would open that
|
|
191
|
+
* window; `tests/whatsapp-media-capped-read.test.ts` pins the shape.
|
|
198
192
|
* - **A dead body is silent.** When the connection resets mid-body, Bun's
|
|
199
193
|
* `Readable.fromWeb` source closes errored and emits no `'error'`, and
|
|
200
194
|
* `pipe()` forwards neither, so the Transform simply stops receiving. No
|
|
@@ -290,7 +284,7 @@ export function readStreamCapped(
|
|
|
290
284
|
}
|
|
291
285
|
|
|
292
286
|
/**
|
|
293
|
-
* The bound on
|
|
287
|
+
* The bound on the download's open: the media fetch up to its response headers, under
|
|
294
288
|
* the idle bound, and a phone re-upload in between, under its own bound. One
|
|
295
289
|
* timer, re-armed as the phase changes; `rejected` settles when it fires.
|
|
296
290
|
*/
|
|
@@ -342,6 +336,119 @@ function openBound(
|
|
|
342
336
|
};
|
|
343
337
|
}
|
|
344
338
|
|
|
339
|
+
/** WhatsApp media is AES-256-CBC in 16-byte blocks, followed by a 10-byte MAC. */
|
|
340
|
+
const AES_BLOCK = 16;
|
|
341
|
+
|
|
342
|
+
/** Thrown for a media URL that did not answer 2xx; the caller reports `download_failed`. */
|
|
343
|
+
export class MediaFetchError extends Error {
|
|
344
|
+
override readonly name = "MediaFetchError";
|
|
345
|
+
constructor(readonly status: number) {
|
|
346
|
+
super(`WhatsApp media fetch failed: HTTP ${status}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Decrypt WhatsApp's media encryption as a stream: feed whole AES blocks to
|
|
352
|
+
* the decipher and hold back the remainder, which at the end is the 10-byte
|
|
353
|
+
* MAC and is dropped. The same arithmetic as Baileys' `downloadEncryptedContent`
|
|
354
|
+
* (no range support: we always fetch the whole file).
|
|
355
|
+
*/
|
|
356
|
+
function decryptMediaStream(cipherKey: Uint8Array, iv: Uint8Array): Transform {
|
|
357
|
+
const decipher = createDecipheriv("aes-256-cbc", cipherKey, iv);
|
|
358
|
+
let remainder: Buffer = Buffer.alloc(0);
|
|
359
|
+
return new Transform({
|
|
360
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
361
|
+
const data = remainder.length ? Buffer.concat([remainder, chunk]) : chunk;
|
|
362
|
+
const whole = Math.floor(data.length / AES_BLOCK) * AES_BLOCK;
|
|
363
|
+
remainder = data.subarray(whole);
|
|
364
|
+
try {
|
|
365
|
+
callback(null, decipher.update(data.subarray(0, whole)));
|
|
366
|
+
} catch (error) {
|
|
367
|
+
callback(error as Error);
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
flush(callback) {
|
|
371
|
+
try {
|
|
372
|
+
callback(null, decipher.final());
|
|
373
|
+
} catch (error) {
|
|
374
|
+
callback(error as Error);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** `new URL(url).host`, or undefined for a missing or unparsable URL. */
|
|
381
|
+
function hostOf(url: string | null | undefined): string | undefined {
|
|
382
|
+
if (!url) return undefined;
|
|
383
|
+
try {
|
|
384
|
+
return new URL(url).host;
|
|
385
|
+
} catch {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The production download: the media URL, fetched by us so the request
|
|
392
|
+
* carries `context.signal` (the only thing that closes its socket), and piped
|
|
393
|
+
* through the decrypt. Baileys supplies what it is the authority on — which
|
|
394
|
+
* content is the media, the URL from its `directPath`, the keys — and none of
|
|
395
|
+
* the I/O. Mirrors Baileys 7.0.0-rc14's `downloadMediaMessage` except that it
|
|
396
|
+
* never requests a phone re-upload (see `WHATSAPP_REUPLOAD_BOUND_MS`).
|
|
397
|
+
*/
|
|
398
|
+
async function fetchDecryptedMedia(
|
|
399
|
+
msg: WAMessage,
|
|
400
|
+
_sock: WASocket,
|
|
401
|
+
context: MediaDownloadContext,
|
|
402
|
+
): Promise<Readable> {
|
|
403
|
+
const content = extractMessageContent(msg.message);
|
|
404
|
+
const contentType = content ? getContentType(content) : undefined;
|
|
405
|
+
const media = (content && contentType ? content[contentType] : undefined) as
|
|
406
|
+
| {
|
|
407
|
+
url?: string | null;
|
|
408
|
+
directPath?: string | null;
|
|
409
|
+
thumbnailDirectPath?: string | null;
|
|
410
|
+
mediaKey?: Uint8Array | null;
|
|
411
|
+
}
|
|
412
|
+
| undefined;
|
|
413
|
+
if (
|
|
414
|
+
!contentType ||
|
|
415
|
+
!media ||
|
|
416
|
+
typeof media !== "object" ||
|
|
417
|
+
(!("url" in media) && !("thumbnailDirectPath" in media))
|
|
418
|
+
) {
|
|
419
|
+
throw new Error(`"${contentType}" message is not a media message`);
|
|
420
|
+
}
|
|
421
|
+
// Baileys' keying: the HKDF info string comes from the content type, and a
|
|
422
|
+
// wrong one decrypts to garbage silently (CBC does not authenticate).
|
|
423
|
+
let mediaType = contentType.replace("Message", "") as BaileysMediaType;
|
|
424
|
+
let directPath = media.directPath;
|
|
425
|
+
if ("thumbnailDirectPath" in media && !("url" in media)) {
|
|
426
|
+
directPath = media.thumbnailDirectPath;
|
|
427
|
+
mediaType = "thumbnail-link";
|
|
428
|
+
}
|
|
429
|
+
const url = directPath
|
|
430
|
+
? getUrlFromDirectPath(directPath, hostOf(media.url))
|
|
431
|
+
: media.url;
|
|
432
|
+
if (!url) throw new Error("No media URL or directPath in the message");
|
|
433
|
+
|
|
434
|
+
const { cipherKey, iv } = await getMediaKeys(media.mediaKey, mediaType);
|
|
435
|
+
const response = await fetch(url, {
|
|
436
|
+
signal: context.signal,
|
|
437
|
+
headers: { Origin: DEFAULT_ORIGIN },
|
|
438
|
+
});
|
|
439
|
+
if (!response.ok || !response.body) {
|
|
440
|
+
// A non-2xx body is never read; `downloadBytes`' abort closes the socket.
|
|
441
|
+
response.body?.cancel().catch((error: unknown) =>
|
|
442
|
+
logger.debug("WhatsApp media non-2xx body cancel failed", {
|
|
443
|
+
error: error instanceof Error ? error.message : String(error),
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
throw new MediaFetchError(response.status);
|
|
447
|
+
}
|
|
448
|
+
const body = Readable.fromWeb(response.body as WebReadableStream);
|
|
449
|
+
return body.pipe(decryptMediaStream(cipherKey, iv));
|
|
450
|
+
}
|
|
451
|
+
|
|
345
452
|
async function downloadBytes(
|
|
346
453
|
msg: WAMessage,
|
|
347
454
|
sock: WASocket,
|
|
@@ -352,7 +459,7 @@ async function downloadBytes(
|
|
|
352
459
|
bounds.idleMs,
|
|
353
460
|
options.reuploadBoundMs ?? WHATSAPP_REUPLOAD_BOUND_MS,
|
|
354
461
|
);
|
|
355
|
-
// Destroying the stream
|
|
462
|
+
// Destroying the download's stream, or its HTTP source, releases the
|
|
356
463
|
// reader but leaves the socket draining the body (measured on Bun 1.3.14:
|
|
357
464
|
// still receiving after the destroy, closed within 1 ms of an abort). So
|
|
358
465
|
// every exit aborts; after a clean end the abort is a no-op. The abort can
|
|
@@ -375,18 +482,11 @@ async function downloadBytes(
|
|
|
375
482
|
};
|
|
376
483
|
try {
|
|
377
484
|
open.arm("open");
|
|
378
|
-
const opening = options.download
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
{ options: { signal: controller.signal } },
|
|
384
|
-
{
|
|
385
|
-
// biome-ignore lint/suspicious/noExplicitAny: Baileys logger type is complex
|
|
386
|
-
logger: silentBaileysLogger as any,
|
|
387
|
-
reuploadRequest: context.reuploadRequest,
|
|
388
|
-
},
|
|
389
|
-
) as Promise<Readable>);
|
|
485
|
+
const opening = (options.download ?? fetchDecryptedMedia)(
|
|
486
|
+
msg,
|
|
487
|
+
sock,
|
|
488
|
+
context,
|
|
489
|
+
);
|
|
390
490
|
// A stream that arrives after the bound fired is torn down on arrival.
|
|
391
491
|
opening.then(
|
|
392
492
|
(stream) => {
|