mercury-agent 0.8.3 → 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.3",
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";
@@ -803,9 +803,11 @@ export class AgentContainerRunner {
803
803
  input.extraEnv?.ANTHROPIC_OAUTH_TOKEN,
804
804
  );
805
805
  if (!hasAnthropicKey && this.config.modelProvider === "anthropic") {
806
+ const resolvedAuthPath =
807
+ this.config.authPath ?? path.join(globalDir, "auth.json");
806
808
  const piAuth = await getPiAuthCredential({
807
809
  provider: this.config.modelProvider,
808
- authPath: this.config.authPath ?? path.join(globalDir, "auth.json"),
810
+ authPath: resolvedAuthPath,
809
811
  });
810
812
  if (piAuth.status === "ok") {
811
813
  passthroughEnvPairs.push({
@@ -819,10 +821,11 @@ export class AgentContainerRunner {
819
821
  const detail = oauthBlobCorrupt
820
822
  ? "MERCURY_ANTHROPIC_OAUTH_TOKEN holds a corrupt credential blob — re-provision it or set MERCURY_ANTHROPIC_API_KEY"
821
823
  : piAuth.status === "refresh-failed"
822
- ? "Anthropic OAuth refresh failed — re-authenticate on the host (mercury auth login) or set MERCURY_ANTHROPIC_API_KEY"
823
- : "no Anthropic credential configured — run mercury auth login or set MERCURY_ANTHROPIC_API_KEY / MERCURY_ANTHROPIC_OAUTH_TOKEN";
824
+ ? `Anthropic OAuth refresh failed for ${resolvedAuthPath} — re-authenticate on the host (run mercury auth login from the project directory that owns that file) or set MERCURY_ANTHROPIC_API_KEY`
825
+ : `no Anthropic credential configured (checked ${resolvedAuthPath}) — run mercury auth login or set MERCURY_ANTHROPIC_API_KEY / MERCURY_ANTHROPIC_OAUTH_TOKEN`;
824
826
  logger.error(`Refusing to start container: ${detail}`, {
825
827
  spaceId: input.spaceId,
828
+ authPath: resolvedAuthPath,
826
829
  });
827
830
  throw ContainerError.noCredentials(input.spaceId, detail);
828
831
  }
@@ -18,6 +18,7 @@ import { basename, dirname, join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { Command } from "commander";
20
20
  import { loadConfig, resolveProjectPath } from "../config.js";
21
+ import { mergeRawMercuryConfig } from "../config-file.js";
21
22
  import { getCatalogEntryByName } from "../extensions/catalog.js";
22
23
  import {
23
24
  checkExtensionIndexLoads,
@@ -100,6 +101,39 @@ function loadEnvFile(envPath: string): Record<string, string> {
100
101
  return vars;
101
102
  }
102
103
 
104
+ /**
105
+ * Resolve the auth.json path the way the running service does: `.env` values
106
+ * win over process env (mirroring runAction's Object.assign), mercury.yaml
107
+ * sits under both, and MERCURY_AUTH_PATH / runtime.auth_path override the
108
+ * default `<dataDir>/global/auth.json`. Falls back to the defaults if the
109
+ * project config is malformed — auth commands must stay usable to fix it.
110
+ */
111
+ function resolveAuthJsonPath(): { authPath: string; dataDir: string } {
112
+ const envPath = join(CWD, ".env");
113
+ const envVars = existsSync(envPath) ? loadEnvFile(envPath) : {};
114
+ let authOverride: string | undefined;
115
+ let dataDir = ".mercury";
116
+ try {
117
+ const raw = mergeRawMercuryConfig({ ...process.env, ...envVars }, CWD);
118
+ if (typeof raw.authPath === "string" && raw.authPath) {
119
+ authOverride = raw.authPath;
120
+ }
121
+ if (typeof raw.dataDir === "string" && raw.dataDir) {
122
+ dataDir = raw.dataDir;
123
+ }
124
+ } catch (e) {
125
+ console.warn(
126
+ `[WARN] ${e instanceof Error ? e.message : String(e)} — using default auth path`,
127
+ );
128
+ }
129
+ return {
130
+ authPath: resolveProjectPath(
131
+ authOverride ?? join(dataDir, "global", "auth.json"),
132
+ ),
133
+ dataDir,
134
+ };
135
+ }
136
+
103
137
  function withProjectDb<T>(fn: (db: Db) => T): T {
104
138
  const dbPath = join(CWD, getProjectDataDir(CWD), "state.db");
105
139
  const db = new Db(dbPath);
@@ -474,8 +508,7 @@ function doctorAction(): void {
474
508
 
475
509
  // 4. AI credentials
476
510
  console.log("\nAI Credentials:");
477
- const dataDir = getProjectDataDir(CWD);
478
- const authPath = join(CWD, dataDir, "global", "auth.json");
511
+ const { authPath } = resolveAuthJsonPath();
479
512
  const hasOAuth = existsSync(authPath);
480
513
  const hasApiKey = !!(
481
514
  process.env.MERCURY_ANTHROPIC_API_KEY ||
@@ -601,7 +634,7 @@ function doctorAction(): void {
601
634
 
602
635
  // 8. Spaces exist
603
636
  console.log("\nSpaces:");
604
- const dbPath = join(CWD, dataDir, "state.db");
637
+ const dbPath = join(CWD, getProjectDataDir(CWD), "state.db");
605
638
  if (existsSync(dbPath)) {
606
639
  try {
607
640
  const db = new Db(dbPath);
@@ -963,9 +996,31 @@ authCommand
963
996
  if (!provider) throw new Error(`Unknown provider: ${providerId}`);
964
997
  console.log(`\nLogging in to ${provider.name}...`);
965
998
 
966
- // Resolve auth.json path
967
- const dataDir = getProjectDataDir(CWD);
968
- const authPath = join(CWD, dataDir, "global", "auth.json");
999
+ // Resolve auth.json path exactly like the running service does
1000
+ // (.env + mercury.yaml, MERCURY_AUTH_PATH / runtime.auth_path override).
1001
+ const { authPath, dataDir } = resolveAuthJsonPath();
1002
+
1003
+ // Credentials are CWD-scoped: a service running from another directory
1004
+ // reads its own <project>/<dataDir>/global/auth.json and will never see
1005
+ // tokens saved here. Warn before silently creating a stray .mercury tree.
1006
+ // A bare data dir doesn't count as a project marker — a prior misplaced
1007
+ // login creates exactly that; state.db only exists where Mercury has run.
1008
+ const looksLikeMercuryProject =
1009
+ existsSync(join(CWD, "mercury.yaml")) ||
1010
+ existsSync(join(CWD, ".env")) ||
1011
+ existsSync(join(CWD, dataDir, "state.db"));
1012
+ if (!looksLikeMercuryProject) {
1013
+ console.warn(
1014
+ `\n⚠ ${CWD} does not look like a Mercury project (no mercury.yaml, .env, or ${dataDir}/state.db).`,
1015
+ );
1016
+ console.warn(
1017
+ ` Credentials will be saved to ${authPath} — a Mercury service running from a different directory will NOT read them.`,
1018
+ );
1019
+ console.warn(
1020
+ " If you meant to re-authenticate a running service, re-run this command from that project's directory.",
1021
+ );
1022
+ }
1023
+
969
1024
  const authDir = dirname(authPath);
970
1025
  if (!existsSync(authDir)) {
971
1026
  mkdirSync(authDir, { recursive: true });
@@ -1069,8 +1124,7 @@ authCommand
1069
1124
  .command("logout [provider]")
1070
1125
  .description("Remove saved OAuth credentials for a provider")
1071
1126
  .action(async (providerArg?: string) => {
1072
- const dataDir = getProjectDataDir(CWD);
1073
- const authPath = join(CWD, dataDir, "global", "auth.json");
1127
+ const { authPath } = resolveAuthJsonPath();
1074
1128
 
1075
1129
  if (!existsSync(authPath)) {
1076
1130
  console.log("No credentials found.");
@@ -1113,8 +1167,7 @@ authCommand
1113
1167
  .action(async () => {
1114
1168
  const { getOAuthProviders } = await import("@earendil-works/pi-ai/oauth");
1115
1169
 
1116
- const dataDir = getProjectDataDir(CWD);
1117
- const authPath = join(CWD, dataDir, "global", "auth.json");
1170
+ const { authPath } = resolveAuthJsonPath();
1118
1171
 
1119
1172
  let authData: Record<string, { type?: string; expires?: number }> = {};
1120
1173
  if (existsSync(authPath)) {
@@ -96,7 +96,7 @@ export async function getPiAuthCredential(options: {
96
96
  return { status: "ok", apiKey: result.apiKey };
97
97
  } catch (error) {
98
98
  logger.warn(
99
- "Failed to load anthropic oauth token from pi auth.json",
99
+ `Failed to load anthropic oauth token from pi auth.json at ${authPath}`,
100
100
  error instanceof Error ? error : undefined,
101
101
  );
102
102
  return {