mercury-agent 0.8.7 → 0.8.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -1288,12 +1288,43 @@ export class MercuryCoreRuntime {
1288
1288
  // Container-relative workspace path
1289
1289
  const containerWorkspace = `/spaces/${spaceId}`;
1290
1290
 
1291
+ // Resolve the reply target once — reused for isolation, context assembly,
1292
+ // and DB linkage. Non-null only when the quoted platform message is
1293
+ // recorded in THIS conversation's history (keyed by platform +
1294
+ // conversation + platform message id).
1295
+ let replyMercuryMsgId: number | null = null;
1296
+ if (
1297
+ replyMeta?.replyToPlatformMessageId &&
1298
+ replyMeta.platform &&
1299
+ replyMeta.conversationExternalId
1300
+ ) {
1301
+ replyMercuryMsgId = this.db.lookupMercuryMessageId(
1302
+ replyMeta.platform,
1303
+ replyMeta.conversationExternalId,
1304
+ replyMeta.replyToPlatformMessageId,
1305
+ );
1306
+ }
1307
+ const userReplyToId = replyMercuryMsgId ?? undefined;
1308
+
1291
1309
  // ── Reply-chain isolation ──────────────────────────────────────────
1292
1310
  // Strip quoted bot output from unprivileged group replies-to-bot
1293
1311
  // BEFORE hooks see the prompt (defense in depth).
1312
+ //
1313
+ // Only isolate when the quoted message is NOT recorded in this
1314
+ // conversation's own history (replyMercuryMsgId === null). A recorded
1315
+ // match means the bot posted it publicly in this same conversation and
1316
+ // the member has already seen it — there is nothing to protect, so
1317
+ // isolating would only strip legitimate context. A miss means the quote
1318
+ // originated elsewhere (a DM, another space, or unverifiable content) —
1319
+ // keep isolating. Fail-safe: if outbound recording ever missed the
1320
+ // message, the lookup returns null and isolation still fires.
1294
1321
  let replyIsolated = false;
1295
1322
  let finalPrompt = prompt;
1296
- if (replyFlags?.isReplyToBot && !replyFlags.isDM) {
1323
+ if (
1324
+ replyFlags?.isReplyToBot &&
1325
+ !replyFlags.isDM &&
1326
+ replyMercuryMsgId === null
1327
+ ) {
1297
1328
  const seededAdmins = this.config.admins
1298
1329
  ? this.config.admins
1299
1330
  .split(",")
@@ -1393,21 +1424,6 @@ export class MercuryCoreRuntime {
1393
1424
  };
1394
1425
  }
1395
1426
 
1396
- // Resolve reply target once — reused for context assembly and DB linkage.
1397
- let replyMercuryMsgId: number | null = null;
1398
- if (
1399
- replyMeta?.replyToPlatformMessageId &&
1400
- replyMeta.platform &&
1401
- replyMeta.conversationExternalId
1402
- ) {
1403
- replyMercuryMsgId = this.db.lookupMercuryMessageId(
1404
- replyMeta.platform,
1405
- replyMeta.conversationExternalId,
1406
- replyMeta.replyToPlatformMessageId,
1407
- );
1408
- }
1409
- const userReplyToId = replyMercuryMsgId ?? undefined;
1410
-
1411
1427
  const replyChainDepthStr = this.db.getSpaceConfig(
1412
1428
  spaceId,
1413
1429
  "context.reply_chain_depth",
@@ -415,42 +415,148 @@ function extImageRepo(agentId: string | undefined): string {
415
415
  return agentId ? `mercury-agent-ext-${agentId}` : "mercury-agent-ext";
416
416
  }
417
417
 
418
+ /**
419
+ * Runs a `docker` subcommand and returns stdout. Throws on non-zero exit,
420
+ * with the error message carrying the tail of stderr for diagnosis.
421
+ *
422
+ * Injected into the prune helpers so their logic (container reaping, flag
423
+ * fallback, failure logging) is unit-testable without a real Docker daemon.
424
+ */
425
+ export type DockerRun = (args: string[], timeoutMs?: number) => string;
426
+
427
+ const realDockerRun: DockerRun = (args, timeoutMs = 30_000) => {
428
+ try {
429
+ return execFileSync("docker", args, {
430
+ encoding: "utf8",
431
+ timeout: timeoutMs,
432
+ stdio: ["ignore", "pipe", "pipe"],
433
+ });
434
+ } catch (err) {
435
+ // execFileSync throws an Error with `.stderr` populated (we piped it).
436
+ // Fold stderr into the message so callers logging `err.message` see why.
437
+ const stderr =
438
+ err && typeof err === "object" && "stderr" in err
439
+ ? String((err as { stderr: unknown }).stderr ?? "").trim()
440
+ : "";
441
+ const base = err instanceof Error ? err.message : String(err);
442
+ throw new Error(stderr ? `${base}: ${stderr}` : base);
443
+ }
444
+ };
445
+
446
+ const errMsg = (err: unknown): string =>
447
+ err instanceof Error ? err.message : String(err);
448
+
418
449
  /**
419
450
  * Remove all derived images for this agent except the one with `keepHash`.
420
- * Images still in use by a running container are skipped silently.
451
+ *
452
+ * A stale image can be pinned by a leftover *stopped* container (e.g. one that
453
+ * survived an abnormal exit where `--rm` never reaped it). When the first
454
+ * `docker rmi` fails we remove the stopped containers referencing that image
455
+ * (plain `docker rm`, which never touches a *running* container) and retry.
456
+ * If the image is still pinned — a container is genuinely running on it — we
457
+ * `log.warn` and move on rather than swallowing the failure silently, so the
458
+ * accumulation is visible in logs.
421
459
  */
422
- function pruneStaleExtImages(
460
+ export function pruneStaleExtImages(
423
461
  keepHash: string,
424
462
  repo: string,
425
463
  log: Logger,
464
+ run: DockerRun = realDockerRun,
426
465
  ): void {
466
+ let tags: string[];
427
467
  try {
428
- const out = execFileSync(
429
- "docker",
430
- ["images", repo, "--format", "{{.Tag}}"],
431
- { encoding: "utf8", timeout: 30_000 },
432
- );
433
- const tags = out
468
+ const out = run(["images", repo, "--format", "{{.Tag}}"]);
469
+ tags = out
434
470
  .split("\n")
435
471
  .map((t) => t.trim())
436
472
  .filter((t) => t && t !== "<none>");
437
- for (const tag of tags) {
438
- if (tag === keepHash) continue;
439
- try {
440
- execFileSync("docker", ["rmi", `${repo}:${tag}`], {
441
- encoding: "utf8",
442
- timeout: 30_000,
443
- stdio: ["ignore", "pipe", "pipe"],
444
- });
445
- log.info(`Pruned stale ext image ${repo}:${tag}`);
446
- } catch {
447
- // Image still in use by a container — skip silently
473
+ } catch (err) {
474
+ log.warn(`Could not list ext images for pruning: ${errMsg(err)}`);
475
+ return;
476
+ }
477
+
478
+ for (const tag of tags) {
479
+ if (tag === keepHash) continue;
480
+ const image = `${repo}:${tag}`;
481
+ try {
482
+ run(["rmi", image]);
483
+ log.info(`Pruned stale ext image ${image}`);
484
+ continue;
485
+ } catch {
486
+ // Likely pinned by a leftover container — reap stopped ones and retry.
487
+ }
488
+ try {
489
+ const ids = run(["ps", "-aq", "--filter", `ancestor=${image}`])
490
+ .split("\n")
491
+ .map((s) => s.trim())
492
+ .filter(Boolean);
493
+ if (ids.length > 0) {
494
+ // Plain `rm` (no -f): removes stopped containers, errors on running
495
+ // ones without killing them. Best-effort — ignore its failure and
496
+ // let the retry `rmi` be the source of truth.
497
+ try {
498
+ run(["rm", ...ids]);
499
+ } catch {
500
+ // Some referenced container is still running; it stays alive.
501
+ }
448
502
  }
503
+ run(["rmi", image]);
504
+ log.info(
505
+ `Pruned stale ext image ${image} (after reaping ${ids.length} stopped container(s))`,
506
+ );
507
+ } catch (err) {
508
+ log.warn(
509
+ `Could not prune stale ext image ${image} (still referenced by a running container?): ${errMsg(err)}`,
510
+ );
511
+ }
512
+ }
513
+ }
514
+
515
+ /** Default BuildKit cache size to retain after a successful build. */
516
+ const DEFAULT_BUILD_CACHE_RESERVED = "10GB";
517
+
518
+ /**
519
+ * Bound the BuildKit build cache after a successful build so it doesn't grow
520
+ * without limit (observed reaching 27 GB in the field). Best-effort and
521
+ * non-fatal: any failure is logged and the build result is unaffected.
522
+ *
523
+ * Retained size is configurable via `MERCURY_BUILD_CACHE_RESERVED` (a docker
524
+ * size string like `10GB`); set it to `off`/`0` to disable pruning entirely.
525
+ *
526
+ * Docker renamed the retention flag: modern Docker (buildx) uses
527
+ * `--reserved-space`, older releases used `--keep-storage`. We try the modern
528
+ * flag first and fall back to the legacy one if it's unrecognised.
529
+ */
530
+ export function pruneBuildCache(
531
+ log: Logger,
532
+ run: DockerRun = realDockerRun,
533
+ ): void {
534
+ const configured = process.env.MERCURY_BUILD_CACHE_RESERVED?.trim();
535
+ const reserved = configured || DEFAULT_BUILD_CACHE_RESERVED;
536
+ if (reserved === "0" || reserved.toLowerCase() === "off") {
537
+ log.debug("Build cache prune disabled (MERCURY_BUILD_CACHE_RESERVED=off)");
538
+ return;
539
+ }
540
+
541
+ const flags = ["--reserved-space", "--keep-storage"];
542
+ for (let i = 0; i < flags.length; i++) {
543
+ try {
544
+ run(["builder", "prune", "-f", `${flags[i]}=${reserved}`], 120_000);
545
+ log.info(`Pruned BuildKit build cache (${flags[i]}=${reserved})`);
546
+ return;
547
+ } catch (err) {
548
+ const message = errMsg(err);
549
+ // Docker/cobra emits "unknown flag: --x" / "unknown shorthand flag" when
550
+ // a flag isn't recognised. Match only those — a genuine value/daemon
551
+ // error ("invalid argument …", "failed to prune …") must NOT trigger the
552
+ // legacy retry, so it surfaces as a real warning instead of being masked.
553
+ const unknownFlag = /unknown (flag|shorthand)/i.test(message);
554
+ // Only fall through to the legacy flag when the modern one is
555
+ // unrecognised; any other failure is real and shouldn't be masked.
556
+ if (unknownFlag && i < flags.length - 1) continue;
557
+ log.warn(`Could not prune build cache: ${message}`);
558
+ return;
449
559
  }
450
- } catch (err) {
451
- log.warn(
452
- `Could not list ext images for pruning: ${err instanceof Error ? err.message : String(err)}`,
453
- );
454
560
  }
455
561
  }
456
562
 
@@ -596,7 +702,16 @@ export async function ensureDerivedImage(
596
702
  const durationMs = Date.now() - startTime;
597
703
 
598
704
  log.info(`Built derived agent image ${derivedTag}`, { durationMs });
599
- pruneStaleExtImages(hash, repo, log);
705
+ // Post-build disk hygiene is strictly best-effort: the build already
706
+ // succeeded, so a prune failure must never turn this into the base-image
707
+ // fallback path. Both helpers are internally guarded; this outer catch is
708
+ // belt-and-suspenders so a future edit inside them can't break the success.
709
+ try {
710
+ pruneStaleExtImages(hash, repo, log);
711
+ pruneBuildCache(log);
712
+ } catch (pruneErr) {
713
+ log.warn(`Post-build prune failed (non-fatal): ${String(pruneErr)}`);
714
+ }
600
715
  return derivedTag;
601
716
  } catch (err: unknown) {
602
717
  const stderr =