mercury-agent 0.24.3-beta.1 → 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.
@@ -2298,6 +2298,12 @@ The media download fetches and decrypts itself instead of through a patched Bail
2298
2298
 
2299
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
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
+
2301
2307
  ## Plan unattended-1003 / S7
2302
2308
 
2303
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.
@@ -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 refusals (budget spent, Brave unavailable) say the same instead of pointing at the browser. 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.
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 web search starts refusing on that host until the next month.
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. Do not call web-search again in this run. ${NO_SCRAPED_SEARCH}`,
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: permanent
628
- ? `Brave search is unavailable this run (Brave returned ${res.status}: ${detail}). Do not retry web-search. ${NO_SCRAPED_SEARCH}`
629
- : `Brave returned ${res.status}: ${detail}`,
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 = `Brave search on this host: ${used} of ${deps.monthlyBudget} billed searches used in ${month} (${threshold}% of web-search.monthly_budget).`;
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} Web search now refuses on this host until next month. After topping up the Brave account, raise web-search.monthly_budget in the @global scope.`
700
- : `${head} At 100% web search stops on this host until next month.`,
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. The rule forbids every
90
- * keyword-search path but the capability, so it is only true while the
91
- * capability can answer: with no key every call is a 503, and the prompt would
92
- * leave a keyless install with no keyword search at all. Without a key this
93
- * extension states no search policy, exactly like a box without web-search
94
- * (the browser extension states none either); the skill and its 503 remain.
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(rawKey: string | undefined): string {
98
- return braveApiKey(rawKey) === ""
99
- ? PRICE_ROUTING_PROMPT
100
- : SEARCH_ROUTING_PROMPT;
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%, refuses at 100%. 0 blocks every call.",
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: readCap(
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
- mercury.on("before_container", async () => {
172
- return { systemPrompt: searchRoutingPrompt(process.env[BRAVE_ENV]) };
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 budget already spent? For checks that must run
67
- * before something else is written (the hourly window), so a spent month is
68
- * reported as itself rather than as whichever limit happened to come first.
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
- return readRow(store, ledgerKey(provider, ledgerMonth(now))).count >= budget;
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.1",
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",