mercury-agent 0.8.4 → 0.8.5

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.
@@ -11,7 +11,7 @@ import {
11
11
  import { createRequire } from "node:module";
12
12
  import { homedir, tmpdir } from "node:os";
13
13
  import { delimiter, dirname, join } from "node:path";
14
- import { getApiKeyFromPiAuthFile } from "mercury-agent/storage/pi-auth";
14
+ import { getPiAuthCredential } from "mercury-agent/storage/pi-auth";
15
15
 
16
16
  const KNOWLEDGE_DIR = "knowledge";
17
17
  const VAULT_DIRS = ["people", "projects", "references", "daily", "episodes", "weekly", "monthly", "templates"];
@@ -407,11 +407,15 @@ export default function (mercury: {
407
407
  // LLM credential resolution for host-side pi spawns
408
408
  // ---------------------------------------------------------------------------
409
409
 
410
+ type PiAuthEnvResult =
411
+ | { ok: true; env: Record<string, string> }
412
+ | { ok: false; reason: string };
413
+
410
414
  async function resolvePiAuthEnv(config: {
411
415
  authPath?: string;
412
416
  globalDir: string;
413
417
  modelProvider: string;
414
- }): Promise<Record<string, string>> {
418
+ }): Promise<PiAuthEnvResult> {
415
419
  const env: Record<string, string> = {};
416
420
 
417
421
  // 1. Explicit env vars (strip MERCURY_ prefix, matching container-runner)
@@ -421,18 +425,36 @@ export default function (mercury: {
421
425
  if (process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN) {
422
426
  env.ANTHROPIC_API_KEY = process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN;
423
427
  }
428
+ if (env.ANTHROPIC_API_KEY) return { ok: true, env };
429
+
430
+ // Spawned pi inherits process.env, so an unprefixed host key also works.
431
+ if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN) {
432
+ return { ok: true, env };
433
+ }
424
434
 
425
435
  // 2. Fall back to Mercury's auth.json (OAuth token refresh)
426
- if (!env.ANTHROPIC_API_KEY) {
427
- const authPath = config.authPath ?? join(config.globalDir, "auth.json");
428
- const key = await getApiKeyFromPiAuthFile({
429
- provider: config.modelProvider,
430
- authPath,
431
- });
432
- if (key) env.ANTHROPIC_API_KEY = key;
436
+ const authPath = config.authPath ?? join(config.globalDir, "auth.json");
437
+ const cred = await getPiAuthCredential({
438
+ provider: config.modelProvider,
439
+ authPath,
440
+ });
441
+ if (cred.status === "ok") {
442
+ env.ANTHROPIC_API_KEY = cred.apiKey;
443
+ return { ok: true, env };
444
+ }
445
+
446
+ // Non-anthropic providers aren't resolvable here — let pi use its own auth.
447
+ if (config.modelProvider !== "anthropic") {
448
+ return { ok: true, env };
433
449
  }
434
450
 
435
- return env;
451
+ // Fail fast, mirroring the container-runner guard: without a credential
452
+ // every pi spawn exits 1, one per space per pending date, every run.
453
+ const reason =
454
+ cred.status === "refresh-failed"
455
+ ? `Anthropic OAuth refresh failed for ${authPath} — re-authenticate on the host (run mercury auth login from the project directory that owns that file) or set MERCURY_ANTHROPIC_API_KEY`
456
+ : `no Anthropic credential configured (checked ${authPath}) — run mercury auth login or set MERCURY_ANTHROPIC_API_KEY / MERCURY_ANTHROPIC_OAUTH_TOKEN`;
457
+ return { ok: false, reason };
436
458
  }
437
459
 
438
460
  // ---------------------------------------------------------------------------
@@ -453,7 +475,18 @@ export default function (mercury: {
453
475
  return;
454
476
  }
455
477
 
456
- const piAuthEnv = await resolvePiAuthEnv(ctx.config);
478
+ const piAuth = await resolvePiAuthEnv(ctx.config);
479
+ if (!piAuth.ok) {
480
+ // One clear host-level error instead of a failed pi spawn per
481
+ // space per pending date; dates stay pending for the next run.
482
+ ctx.log.error("Skipping distillation: no Anthropic credential", {
483
+ reason: piAuth.reason,
484
+ });
485
+ mercury.store.set("last-distill", new Date().toISOString());
486
+ mercury.store.set("last-distill-status", "skipped: no credential");
487
+ return;
488
+ }
489
+ const piAuthEnv = piAuth.env;
457
490
 
458
491
  const db = new Database(dbPath, { readonly: true });
459
492
 
@@ -681,7 +714,14 @@ export default function (mercury: {
681
714
  const dbPath = join(ctx.config.dataDir, "state.db");
682
715
  if (!existsSync(dbPath)) return;
683
716
 
684
- const piAuthEnv = await resolvePiAuthEnv(ctx.config);
717
+ const piAuth = await resolvePiAuthEnv(ctx.config);
718
+ if (!piAuth.ok) {
719
+ ctx.log.error("Skipping consolidation: no Anthropic credential", {
720
+ reason: piAuth.reason,
721
+ });
722
+ return;
723
+ }
724
+ const piAuthEnv = piAuth.env;
685
725
 
686
726
  const db = new Database(dbPath, { readonly: true });
687
727
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Runtime console hygiene for the libsignal dependency (transitive via Baileys).
3
+ *
4
+ * libsignal writes directly to the global console — bypassing the silenced
5
+ * Baileys logger — and several of those calls dump entire SessionEntry objects,
6
+ * including ratchet private keys and root keys, into the service log
7
+ * (session_record.js: "Opening session:", "Closing session:", ...).
8
+ *
9
+ * Mercury is a published package, so patching node_modules (bun patch /
10
+ * patch-package) would not reach installed copies. Instead we wrap the global
11
+ * console methods libsignal uses with two deterministic layers:
12
+ *
13
+ * 1. Prefix suppression — calls whose first argument is a known libsignal
14
+ * session message are dropped entirely.
15
+ * 2. Shape redaction — any argument that structurally looks like a Signal
16
+ * SessionEntry/SessionRecord is replaced with a placeholder, as a backstop
17
+ * for call sites we don't know about.
18
+ *
19
+ * All other console traffic passes through untouched.
20
+ */
21
+
22
+ /** Known libsignal messages (session_record.js / session_builder.js). */
23
+ const SUPPRESSED_PREFIXES = [
24
+ "Opening session:",
25
+ "Closing session:",
26
+ "Session already open",
27
+ "Session already closed",
28
+ "Removing old closed session:",
29
+ "Migrating session to:",
30
+ "Closing open session in favor of incoming prekey bundle",
31
+ "Closing stale open session for new outgoing prekey bundle",
32
+ ];
33
+
34
+ const REDACTED = "[libsignal session redacted]";
35
+
36
+ function isSessionLike(value: unknown): boolean {
37
+ if (typeof value !== "object" || value === null) return false;
38
+ const obj = value as Record<string, unknown>;
39
+ // SessionEntry: currentRatchet + indexInfo (+ _chains); SessionRecord: sessions map + version.
40
+ if ("currentRatchet" in obj && "indexInfo" in obj) return true;
41
+ if ("_chains" in obj && "registrationId" in obj) return true;
42
+ if ("sessions" in obj && "version" in obj && "haveOpenSession" in obj)
43
+ return true;
44
+ return false;
45
+ }
46
+
47
+ export function filterConsoleArgs(args: unknown[]): unknown[] | null {
48
+ const first = args[0];
49
+ if (
50
+ typeof first === "string" &&
51
+ SUPPRESSED_PREFIXES.some((p) => first.startsWith(p))
52
+ ) {
53
+ return null;
54
+ }
55
+ let changed = false;
56
+ const out = args.map((arg) => {
57
+ if (isSessionLike(arg)) {
58
+ changed = true;
59
+ return REDACTED;
60
+ }
61
+ return arg;
62
+ });
63
+ return changed ? out : args;
64
+ }
65
+
66
+ type ConsoleMethod = (...args: unknown[]) => void;
67
+
68
+ // Tracks wrapper functions so install is idempotent per method: a method whose
69
+ // current function is already one of our wrappers is left alone, while a
70
+ // method that was replaced (e.g. restored in a test) gets re-wrapped.
71
+ const wrappers = new WeakSet<ConsoleMethod>();
72
+
73
+ /**
74
+ * Wrap the global console methods libsignal writes to. Idempotent; call
75
+ * before creating the WhatsApp socket.
76
+ */
77
+ export function installLibsignalConsoleFilter(): void {
78
+ for (const method of ["debug", "log", "info", "warn", "error"] as const) {
79
+ const current: ConsoleMethod = console[method];
80
+ if (wrappers.has(current)) continue;
81
+ const original = current.bind(console);
82
+ const wrapper: ConsoleMethod = (...args: unknown[]) => {
83
+ const filtered = filterConsoleArgs(args);
84
+ if (filtered === null) return;
85
+ original(...filtered);
86
+ };
87
+ wrappers.add(wrapper);
88
+ console[method] = wrapper;
89
+ }
90
+ }
@@ -30,6 +30,7 @@ import {
30
30
  import { logger } from "../logger.js";
31
31
  import { normalizeChatMarkdown } from "../text/markdown.js";
32
32
  import { applyRtlDirection } from "../text/rtl.js";
33
+ import { installLibsignalConsoleFilter } from "./whatsapp-console-filter.js";
33
34
  import {
34
35
  canonicalizeJidSync,
35
36
  resolveKeyIdentities,
@@ -220,6 +221,10 @@ export class WhatsAppBaileysAdapter
220
221
  }
221
222
 
222
223
  private async connect(): Promise<void> {
224
+ // libsignal (transitive via Baileys) writes session dumps — including
225
+ // private key material — straight to the global console, bypassing the
226
+ // silenced waLogger below. Filter before the socket exists.
227
+ installLibsignalConsoleFilter();
223
228
  fs.mkdirSync(this.authDir, { recursive: true });
224
229
  const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
225
230
  const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
@@ -537,8 +542,8 @@ export class WhatsAppBaileysAdapter
537
542
  const sender = identities.sender.canonical;
538
543
  if (identities.sender.changed || identities.chat.changed) {
539
544
  logger.debug("WhatsApp identity canonicalized", {
540
- sender: identities.sender,
541
- chat: identities.chat,
545
+ sender: `${identities.sender.original} -> ${identities.sender.canonical}`,
546
+ chat: `${identities.chat.original} -> ${identities.chat.canonical}`,
542
547
  });
543
548
  }
544
549
  const senderName = msg.pushName || sender.split("@")[0] || "unknown";