mercury-agent 0.18.2 → 0.20.0

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.
Files changed (107) hide show
  1. package/container/Dockerfile +25 -0
  2. package/container/build.sh +23 -3
  3. package/docs/behavior-layers.md +25 -14
  4. package/docs/configuration.md +139 -11
  5. package/docs/container-lifecycle.md +151 -1
  6. package/docs/context-architecture.md +6 -2
  7. package/docs/extensions.md +9 -1
  8. package/docs/goals/football-reporter-profile/decisions.md +79 -0
  9. package/docs/goals/rehearsal-bench/decisions.md +41 -3
  10. package/docs/goals/rehearsal-bench/roadmap.md +3 -1
  11. package/docs/goals/release-gate/decisions.md +86 -0
  12. package/docs/goals/release-gate/roadmap.md +36 -3
  13. package/docs/live-testing.md +22 -0
  14. package/docs/pending-verification.md +178 -0
  15. package/docs/permissions.md +1 -1
  16. package/docs/profile-guide.md +28 -8
  17. package/examples/extensions/archive/backends/local.ts +6 -3
  18. package/examples/extensions/archive/queue.ts +51 -10
  19. package/examples/extensions/feed-watch/config.ts +42 -2
  20. package/examples/extensions/feed-watch/digest.ts +4 -7
  21. package/examples/extensions/feed-watch/items.ts +18 -7
  22. package/examples/extensions/feed-watch/match.ts +125 -9
  23. package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
  24. package/examples/extensions/feed-watch/watch.ts +39 -5
  25. package/examples/extensions/gws/index.ts +126 -8
  26. package/examples/extensions/longview/hook.ts +72 -7
  27. package/examples/extensions/longview/index.ts +2 -0
  28. package/examples/extensions/morning/README.md +26 -15
  29. package/examples/extensions/morning/index.ts +29 -6
  30. package/examples/extensions/morning/lib/hosts.ts +16 -0
  31. package/examples/extensions/morning/lib/morning.ts +78 -0
  32. package/examples/extensions/morning/lib/upload.ts +584 -0
  33. package/examples/extensions/morning/skill/SKILL.md +34 -4
  34. package/examples/extensions/napkin/index.ts +12 -3
  35. package/examples/extensions/napkin/pi-spawn.ts +5 -1
  36. package/examples/extensions/overview/index.ts +20 -0
  37. package/examples/extensions/overview/skill/SKILL.md +9 -1
  38. package/examples/extensions/pinchtab/index.ts +36 -7
  39. package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
  40. package/examples/profiles/_template/AGENTS.md +6 -1
  41. package/examples/profiles/football-reporter/AGENTS.md +16 -4
  42. package/examples/profiles/football-reporter/README.md +1 -1
  43. package/examples/profiles/football-reporter/config.yaml +42 -6
  44. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  45. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
  46. package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
  47. package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
  48. package/examples/profiles/football-reporter/standard.json +45 -8
  49. package/package.json +11 -6
  50. package/resources/skills/tasks/SKILL.md +17 -1
  51. package/resources/templates/AGENTS.md +4 -3
  52. package/resources/templates/mercury.example.yaml +13 -4
  53. package/src/adapters/whatsapp-ingress.ts +15 -3
  54. package/src/agent/container-entry.ts +299 -29
  55. package/src/agent/container-env.ts +26 -8
  56. package/src/agent/container-runner.ts +497 -76
  57. package/src/agent/image-contract.ts +77 -0
  58. package/src/agent/image-manifest.ts +180 -0
  59. package/src/agent/image-refresh.ts +318 -0
  60. package/src/cli/mercury.ts +225 -28
  61. package/src/cli/mrctl-http.ts +5 -0
  62. package/src/cli/mrctl.ts +57 -5
  63. package/src/cli/service-unit.ts +109 -0
  64. package/src/config-file.ts +22 -1
  65. package/src/config.ts +105 -14
  66. package/src/core/api.ts +11 -2
  67. package/src/core/commands.ts +11 -4
  68. package/src/core/connection-health.ts +377 -0
  69. package/src/core/direct-send.ts +235 -14
  70. package/src/core/exec.ts +10 -0
  71. package/src/core/history-window.ts +142 -0
  72. package/src/core/model-command.ts +130 -0
  73. package/src/core/operator-alerts.ts +326 -23
  74. package/src/core/permissions.ts +28 -0
  75. package/src/core/profiles.ts +32 -16
  76. package/src/core/reply-context.ts +31 -0
  77. package/src/core/routes/config-builtin.ts +11 -0
  78. package/src/core/routes/console.ts +98 -16
  79. package/src/core/routes/dashboard.ts +93 -10
  80. package/src/core/routes/model.ts +26 -3
  81. package/src/core/routes/send.ts +1 -1
  82. package/src/core/routes/tasks.ts +74 -0
  83. package/src/core/runtime.ts +302 -21
  84. package/src/core/system-messages.ts +33 -0
  85. package/src/core/task-scheduler.ts +155 -7
  86. package/src/extensions/image-builder.ts +1 -1
  87. package/src/extensions/installer.ts +72 -17
  88. package/src/extensions/load-project.ts +74 -0
  89. package/src/extensions/loader.ts +50 -9
  90. package/src/host-version.ts +32 -0
  91. package/src/main.ts +42 -21
  92. package/src/preflight/checks/credential.ts +300 -0
  93. package/src/preflight/checks/docker.ts +149 -0
  94. package/src/preflight/checks/extensions.ts +90 -0
  95. package/src/preflight/checks/host-deps.ts +247 -0
  96. package/src/preflight/checks/image-contract.ts +228 -0
  97. package/src/preflight/checks/roundtrip.ts +424 -0
  98. package/src/preflight/checks/sandbox.ts +159 -0
  99. package/src/preflight/deps.ts +107 -0
  100. package/src/preflight/probe-container.ts +156 -0
  101. package/src/preflight/report.ts +177 -0
  102. package/src/preflight/run.ts +223 -0
  103. package/src/server.ts +55 -14
  104. package/src/storage/db.ts +41 -2
  105. package/src/storage/models-json.ts +110 -0
  106. package/src/text/reporter-lint.ts +167 -23
  107. package/src/types.ts +22 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.18.2",
3
+ "version": "0.20.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -24,6 +24,10 @@
24
24
  "types": "./src/core/global-admin.ts",
25
25
  "default": "./src/core/global-admin.ts"
26
26
  },
27
+ "./core/media": {
28
+ "types": "./src/core/media.ts",
29
+ "default": "./src/core/media.ts"
30
+ },
27
31
  "./core/model-leg": {
28
32
  "types": "./src/core/model-leg.ts",
29
33
  "default": "./src/core/model-leg.ts"
@@ -80,14 +84,15 @@
80
84
  "typecheck": "bunx tsc --noEmit",
81
85
  "typecheck:scripts": "bunx tsc --noEmit -p tsconfig.scripts.json",
82
86
  "typecheck:examples": "bunx tsc --noEmit -p tsconfig.examples.json",
83
- "lint": "bunx biome check src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
84
- "lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
87
+ "lint": "bunx biome check src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts scripts/archive-erase-space.ts scripts/check-image-sources.ts scripts/check-sync-calls.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
88
+ "lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts scripts/archive-erase-space.ts scripts/check-image-sources.ts scripts/check-sync-calls.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
85
89
  "check:silent-catch": "bun run scripts/check-silent-catch.ts",
86
90
  "check:sync-calls": "bun run scripts/check-sync-calls.ts",
87
91
  "check:dep-floors": "bun run scripts/check-dep-floors.ts",
92
+ "check:image-sources": "bun run scripts/check-image-sources.ts",
88
93
  "check:no-hijack-verbs": "bun run scripts/check-no-hijack-verbs.ts src",
89
- "check": "bun run check:silent-catch && bun run check:sync-calls && bun run check:dep-floors && bun run check:no-hijack-verbs && bun run typecheck && bun run typecheck:scripts && bun run typecheck:examples && bun run lint && bun test",
90
- "check:fix": "bun run check:silent-catch && bun run check:sync-calls && bun run check:dep-floors && bun run check:no-hijack-verbs && bun run typecheck && bun run typecheck:scripts && bun run typecheck:examples && bun run lint:fix && bun test",
94
+ "check": "bun run check:silent-catch && bun run check:sync-calls && bun run check:dep-floors && bun run check:image-sources && bun run check:no-hijack-verbs && bun run typecheck && bun run typecheck:scripts && bun run typecheck:examples && bun run lint && bun test",
95
+ "check:fix": "bun run check:silent-catch && bun run check:sync-calls && bun run check:dep-floors && bun run check:image-sources && bun run check:no-hijack-verbs && bun run typecheck && bun run typecheck:scripts && bun run typecheck:examples && bun run lint:fix && bun test",
91
96
  "smoke:image": "bun run scripts/smoke-image.ts"
92
97
  },
93
98
  "dependencies": {
@@ -110,7 +115,7 @@
110
115
  "zod": "^4.3.6"
111
116
  },
112
117
  "devDependencies": {
113
- "@biomejs/biome": "^2.4.12",
118
+ "@biomejs/biome": "2.5.2",
114
119
  "@types/nodemailer": "^7.0.4",
115
120
  "@types/node": "^26.1.2",
116
121
  "imapflow": "^1.0.171",
@@ -7,11 +7,12 @@ description: Manage scheduled tasks — create cron jobs or one-shot reminders t
7
7
 
8
8
  ```bash
9
9
  mrctl tasks list
10
- mrctl tasks create --cron "<expr>" --prompt "<text>" [--timezone "<IANA>"] [--silent]
10
+ mrctl tasks create --cron "<expr>" --prompt "<text>" [--timezone "<IANA>"] [--silent] [--catch-up skip|once]
11
11
  mrctl tasks create --at "<ISO8601>" --prompt "<text>" [--silent]
12
12
  mrctl tasks pause <id>
13
13
  mrctl tasks resume <id>
14
14
  mrctl tasks run <id>
15
+ mrctl tasks catch-up <id> skip|once
15
16
  mrctl tasks delete <id>
16
17
  ```
17
18
 
@@ -43,6 +44,21 @@ If you cannot determine the timezone, ask the user before creating the task. Nev
43
44
 
44
45
  - `--timezone "<IANA>"` — IANA timezone for cron evaluation (e.g. `"Asia/Jerusalem"`, `"America/New_York"`). Falls back to the agent's configured default timezone, then UTC.
45
46
  - `--silent` — task runs but output is not sent to chat (useful for background work)
47
+ - `--catch-up skip|once` — what to do with occurrences missed while the host was
48
+ down. Cron tasks only; `skip` is the default.
49
+
50
+ ## Missed runs
51
+
52
+ If the host is down when a task was due, the occurrence is **not** run late by
53
+ default — it is logged and reported to whoever created the task, and the task
54
+ resumes at its next scheduled time.
55
+
56
+ Pass `--catch-up once` (or `mrctl tasks catch-up <id> once` on an existing task)
57
+ when a late run is better than no run — a daily summary or article, say. The
58
+ task then runs **one** make-up run at startup, however many occurrences were
59
+ missed, and only if the most recent of them is within the configured window
60
+ (12 hours by default). Leave it at `skip` for anything whose output only makes
61
+ sense at its scheduled hour, such as a "leave for work now" reminder.
46
62
 
47
63
  ## Tasks that are usually quiet
48
64
 
@@ -55,11 +55,12 @@ mrctl config set <key> <value> # Set config value
55
55
 
56
56
  ### Models
57
57
  ```bash
58
- mrctl model list # List configured models (active one marked)
59
- mrctl model switch <N|MODEL_ID> # Switch active model — takes effect on the NEXT agent run
58
+ mrctl model list # List configured models; says whether this space is pinned
59
+ mrctl model switch <N|MODEL_ID> # Pin this space to one leg — takes effect on the NEXT agent run
60
+ mrctl model reset # Clear the pin and restore the fallback chain
60
61
  ```
61
62
 
62
- **Note:** A switch never changes the run you are in — say so when reporting it. Switching requires the `model.switch` permission (admin by default); a 403 means the caller must ask an admin.
63
+ **Note:** A switch never changes the run you are in — say so when reporting it. A switch **pins** the space: that leg is the only one that runs, so the other legs are switch targets, not fallbacks. `mrctl model reset` clears the pin, and is refused while the space has an active scheduled task (pause it first). Both switch and reset require the `model.switch` permission (admin by default); a 403 means the caller must ask an admin.
63
64
 
64
65
  ### Spaces
65
66
  ```bash
@@ -71,10 +71,19 @@
71
71
  # # containers do not start on macOS/Windows. Ignored when
72
72
  # # container_bwrap_docker_compat is set or the runtime is runsc.
73
73
  # allow_unconfined: false
74
- # # Which MERCURY_* vars reach containers. "all" (default) passes every
75
- # # non-blocked var into every space's container; "claimed" passes only
76
- # # extension-declared vars, gated by the caller's permission.
77
- # env_passthrough: all
74
+ # # Which MERCURY_* vars reach containers. "claimed" (default) passes only
75
+ # # extension-declared vars, gated by the caller's permission, plus the fixed
76
+ # # model-provider credentials. "all" is the escape hatch: it passes every
77
+ # # non-blocked var into every space's container, scoped to nothing.
78
+ # env_passthrough: claimed
79
+ # # Who authors pi's system prompt. true (default) replaces it wholesale with
80
+ # # Mercury's own (--system-prompt); false keeps pi's coding-agent preamble
81
+ # # and appends Mercury's content. Leave this at true on a Claude
82
+ # # subscription: Anthropic's OAuth gate rejects pi's preamble as a
83
+ # # third-party app with a 400 that reads like a billing error. Parsed
84
+ # # strictly: only false/0 opt out, a blank MERCURY_OVERRIDE_PI_SYSTEM_PROMPT
85
+ # # is read as unset, and any other value is a startup error.
86
+ # override_pi_system_prompt: true
78
87
 
79
88
  # discord:
80
89
  # gateway_duration_ms: 600000
@@ -106,11 +106,23 @@ export function getContextInfo(
106
106
 
107
107
  /**
108
108
  * These attribute values are user-controlled — a push name is whatever the
109
- * sender typed — and the block is handed to the model as markup. A stray
110
- * double quote would close the attribute and let a sender inject their own.
109
+ * sender typed — and the block is handed to the model as markup.
110
+ *
111
+ * A stray double quote would close the attribute and let a sender inject their
112
+ * own. A stray `>` is worse: it closes the whole opening tag, so every
113
+ * attribute after it — `media_type` included, and `name` is emitted first —
114
+ * reads as quoted body. `replyToBlockHasMedia` (`core/reply-context.ts`) scans
115
+ * the opening tag for `media_type=`, so a `>` in a push name would answer
116
+ * `false` and hand a media quote back to the de-duplication that
117
+ * `docs/debug/moderate/2026-09-06-swipe-reply-dedupe-drops-quoted-media-signal.md`
118
+ * exists to stop. `&` goes first so an escape written here is not re-escaped.
111
119
  */
112
120
  function escapeAttr(value: string): string {
113
- return value.replace(/"/g, "&quot;");
121
+ return value
122
+ .replace(/&/g, "&amp;")
123
+ .replace(/</g, "&lt;")
124
+ .replace(/>/g, "&gt;")
125
+ .replace(/"/g, "&quot;");
114
126
  }
115
127
 
116
128
  export function buildReplyContext(
@@ -39,6 +39,53 @@ function logTiming(
39
39
  /** Trace filename inside IO_DIR. The host harvests this before reaping the container. */
40
40
  export const RUN_TRACE_FILE = "trace.jsonl";
41
41
 
42
+ /**
43
+ * Append one diagnostic event to the run trace, regardless of `TRACE_RUNS`.
44
+ *
45
+ * `logTiming` writes to the container's stderr, and nothing on the host reads
46
+ * it: the container runs detached with `--rm` and no host code parses those
47
+ * lines. So a `logTiming`-only event is a reader that cannot be read — the
48
+ * failure this exists to close (`docs/debug/minor/…-history-budget-backstop-logs-where-nobody-reads.md`).
49
+ *
50
+ * Unlike `createRunTracer`, this is **not** gated on `TRACE_RUNS`: a full trace
51
+ * is opt-in because it carries the conversation verbatim, while an event
52
+ * written here must survive a run nobody thought to trace in advance. The host
53
+ * harvests `trace.jsonl` unconditionally for that reason
54
+ * (`harvestRunTrace`, `container-runner.ts`).
55
+ *
56
+ * **Callers pass counts and identifiers only — never message content.** That is
57
+ * the rule the un-gated harvest rests on: anything written here escapes the
58
+ * opt-in that keeps the conversation off the host's disk, and lands in
59
+ * `dataDir/traces/` on a deployment that deliberately turned tracing off.
60
+ *
61
+ * Returns whether the line was written; a failure is swallowed, because losing
62
+ * a diagnostic must never cost the turn.
63
+ */
64
+ export function traceEvent(
65
+ event: string,
66
+ data: Record<string, number | string | null>,
67
+ ): boolean {
68
+ const ioDir = process.env.IO_DIR;
69
+ if (!ioDir) return false;
70
+ try {
71
+ appendFileSync(
72
+ path.join(ioDir, RUN_TRACE_FILE),
73
+ // `atEpochMs`, not the tracer's `atMs`: these events are emitted while
74
+ // the prompt is still being assembled, before pi is spawned, so there is
75
+ // no spawn to be relative to. `stream: "meta"` keeps the stdout
76
+ // reconstruction (concatenate `stream: "stdout"`) unaffected.
77
+ `${JSON.stringify({
78
+ atEpochMs: Date.now(),
79
+ stream: "meta",
80
+ data: JSON.stringify({ event, ...data }),
81
+ })}\n`,
82
+ );
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
42
89
  type TraceStream = "stdout" | "stderr" | "meta";
43
90
  type RunTracer = (stream: TraceStream, data: string) => void;
44
91
 
@@ -419,12 +466,19 @@ Wrap the message you want delivered between a \`[reply]\` line and a \`[/reply]\
419
466
 
420
467
  Everything a space's standard says about the message — language, length, format, the exact word to send when there is nothing to say — applies to the text inside the markers. Files still go to \`outbox/\`; the markers cover text only.`;
421
468
 
422
- const characterChangeFlow = `## Character
469
+ // The reader instruction for the `## Bot Character` block, which runtime.ts
470
+ // injects on persona-exclusivity, not on permissions — a member in a default
471
+ // space gets the owner's voice and so needs the sentence that governs it.
472
+ // Ungated: it states a fact and a refusal, and carries no procedure.
473
+ const characterStanding = `## Character
423
474
 
424
475
  The system prompt may include a "Bot Character" section — the owner-defined voice for
425
- all conversations. Always follow it.
476
+ all conversations. Always follow it. Only the bot owner (a global admin configured on the
477
+ host) can change it, and the API enforces that. Unless a procedure for changing it appears
478
+ below, treat a request to change your personality, tone, greeting style, or character as
479
+ one you cannot carry out: say in one line that the owner sets it, and change nothing.`;
426
480
 
427
- When a user asks you to change your personality, tone, greeting style, or character:
481
+ const characterChangeFlow = `When a user asks you to change your personality, tone, greeting style, or character:
428
482
  1. Read the current character: \`mrctl character get\`
429
483
  2. Draft the FULL updated character text — merge their request with the existing
430
484
  character into one coherent text. Do not append contradictory fragments.
@@ -434,9 +488,6 @@ When a user asks you to change your personality, tone, greeting style, or charac
434
488
  5. Relay the result. If the API returns 403, tell the user that only the bot owner
435
489
  can change the global character.
436
490
 
437
- Only global admins (configured on the host) can change the character — the API
438
- enforces this.
439
-
440
491
  Per-space adjustments are **preferences**, not the character. They arrive in your
441
492
  prompt as a \`<preferences>\` block of key/value pairs scoped to this space, and you
442
493
  manage them with:
@@ -495,11 +546,14 @@ Your prompt carries a \`<run_budget>\` block with the exact kill time, the total
495
546
  parts.push(destructiveOps);
496
547
  parts.push(toolResultPresentation);
497
548
  parts.push(delivery);
549
+ parts.push(characterStanding);
498
550
  // The character / preference flow is a set of `mrctl prefs set` and
499
551
  // `mrctl character set` instructions. A caller without `prefs.set` cannot
500
- // follow them, and the paragraph's "a standing instruction → set a
501
- // preference" is exactly how a member's joke got escalated to "the admin
502
- // must approve". Left out for that caller; the security text above stays.
552
+ // follow them — `GET /api/character` is global-admin-only, so even
553
+ // `mrctl character get` would 403 — and the paragraph's "a standing
554
+ // instruction → set a preference" is exactly how a member's joke got
555
+ // escalated to "the admin must approve". Left out for that caller; the
556
+ // standing instruction above and the security text stay.
503
557
  if (callerMay("prefs.set")) {
504
558
  parts.push(characterChangeFlow);
505
559
  }
@@ -515,11 +569,13 @@ Your prompt carries a \`<run_budget>\` block with the exact kill time, the total
515
569
  /**
516
570
  * Builds the system prompt passed to pi.
517
571
  *
518
- * - overridePiPrompt=false (default): returns Mercury additions only; pi's own default prompt
519
- * comes first via --append-system-prompt.
520
- * - overridePiPrompt=true: returns a full standalone prompt (pi's default is replaced via
521
- * --system-prompt). Includes tool snippets, guidelines, and Mercury platform content without
572
+ * - overridePiPrompt=true (default): returns a full standalone prompt (pi's default is replaced
573
+ * via --system-prompt). Includes tool snippets, guidelines, and Mercury platform content without
522
574
  * any pi-specific identity.
575
+ * - overridePiPrompt=false: returns Mercury additions only; pi's own default prompt comes first
576
+ * via --append-system-prompt. Under an Anthropic OAuth credential that preamble is rejected
577
+ * with 400 "Third-party apps now draw from your extra usage" — see the JSDoc on
578
+ * `overridePiSystemPrompt` in `src/config.ts`.
523
579
  */
524
580
  function buildSystemPrompt(
525
581
  caps: ModelCapabilities,
@@ -622,8 +678,9 @@ function buildEpisodicMemory(spaceWorkspace: string): string | null {
622
678
  }
623
679
  }
624
680
 
625
- const EPISODE_TOKEN_BUDGET = 800;
626
- const APPROX_CHARS_PER_TOKEN = 4;
681
+ /** Exported so a test asserts the budget the code uses, not a copy of it. */
682
+ export const EPISODE_TOKEN_BUDGET = 800;
683
+ export const APPROX_CHARS_PER_TOKEN = 4;
627
684
 
628
685
  interface EpisodeMeta {
629
686
  title: string;
@@ -765,6 +822,163 @@ export function buildMemberNotesXml(spaceWorkspace: string): string | null {
765
822
  return `<member_notes>\n${entries.join("\n")}\n</member_notes>`;
766
823
  }
767
824
 
825
+ /**
826
+ * The Hebrew prefix letters that attach to the word they govern: ו (and),
827
+ * ה (the), ב (in), ל (to), מ (from), ש (that), כ (as). Up to three stack —
828
+ * `וכשיונייטד` is "and when United" — and the tokenizer below cannot see
829
+ * them, because nothing separates them from the word.
830
+ */
831
+ const HEBREW_PREFIX_RE = /^[ובהלמשכ]{1,3}/;
832
+
833
+ /**
834
+ * A stripped token must leave at least this many characters, or the strip is
835
+ * not taken. Three-letter Hebrew words are where the false positives live:
836
+ * the football profile keys Beitar on `בית`, and without this floor `בבית` /
837
+ * `הבית` / `לבית` — "at home", "the house" — would all select that note.
838
+ */
839
+ const EPISODE_MIN_STEM_CHARS = 4;
840
+
841
+ /**
842
+ * Mem needs one letter more than the rest. ו ה ב ל ש כ are almost always
843
+ * clitics, but `מ` is also how Hebrew builds whole words (מגרש, משחק,
844
+ * משוער), so a four-letter stem behind a leading mem is as likely to be an
845
+ * accident as a mention: `משוער` ("projected") strips to `שוער`
846
+ * ("goalkeeper"), a keyword a football note would plausibly carry, and the
847
+ * phrase `הרכב משוער` is the single most common request in this profile.
848
+ * `מהיונייטד` and `מברצלונה` are unaffected — their stems are long.
849
+ *
850
+ * The accepted cost: a mem before a *four-letter* keyword no longer strips,
851
+ * so `מחיפה` ("from Haifa") does not select the Haifa note while `חיפה` and
852
+ * `בחיפה` still do. Pinned by a test, so it reads as a choice rather than a
853
+ * regression to be helpfully undone.
854
+ */
855
+ const EPISODE_MIN_STEM_CHARS_AFTER_MEM = 5;
856
+
857
+ /**
858
+ * The geresh and gershayim Hebrew writes *inside* a word — `לבית"ר`, `ת"א`,
859
+ * `מנצ'סטר` — in the ASCII forms a phone keyboard emits and the Unicode ones
860
+ * (U+05F3, U+05F4). Dropped when they sit between two Hebrew letters, so the
861
+ * word stays one token and its prefix can be stripped; a quote anywhere else
862
+ * still splits the message, as it always did. The lookahead (rather than a
863
+ * second capture) is what makes a chain of them collapse in one pass.
864
+ *
865
+ * Escapes, not literals: U+05F3/U+05F4 render as `'`/`"` in most editors, so
866
+ * a literal here is indistinguishable from the ASCII pair beside it.
867
+ */
868
+ const HEBREW_INWORD_QUOTE_RE =
869
+ /([\u05D0-\u05EA])['"\u05F3\u05F4]+(?=[\u05D0-\u05EA])/g;
870
+
871
+ /**
872
+ * The words an episode keyword is looked up in: every token of the message,
873
+ * plus every prefix-stripped form of a token that opens with Hebrew prefix
874
+ * letters. `ליונייטד` contributes `ליונייטד`, `יונייטד`; `מהיונייטד`
875
+ * contributes those and `היונייטד` as well.
876
+ *
877
+ * Expanding the message side rather than the keyword side keeps the lookup an
878
+ * exact set membership and keeps the score untouched: a keyword still counts
879
+ * once, whichever form of it the message used. Listing the prefixed forms as
880
+ * extra keywords would instead *lower* the note's score, since `scoreEpisode`
881
+ * divides by the keyword count. Latin words never open with these letters, so
882
+ * English keywords see the exact match they always had.
883
+ *
884
+ * Two shapes reach the splitter first. An in-word geresh/gershayim is dropped
885
+ * (`לבית"ר` → `לביתר`), because splitting there left the 3-letter fragment
886
+ * `בית` that the stem floor is designed to reject. A hyphen or maqaf *is* a
887
+ * split point (`ל-united` → `ל` + `united`), because the anchored prefix
888
+ * regex cannot see past one.
889
+ */
890
+ function episodeMessageWords(userPrompt: string): Set<string> {
891
+ const words = new Set<string>();
892
+ for (const token of userPrompt
893
+ .toLowerCase()
894
+ .replace(HEBREW_INWORD_QUOTE_RE, "$1")
895
+ .split(/[-\s,.!?;:'"()[\]{}<>\u05BE\u05F3\u05F4]+/)) {
896
+ if (token.length <= 1) continue;
897
+ words.add(token);
898
+ const prefix = token.match(HEBREW_PREFIX_RE)?.[0] ?? "";
899
+ for (let n = 1; n <= prefix.length; n++) {
900
+ const stem = token.slice(n);
901
+ // Per strip, not per token: the ambiguity belongs to the letter that
902
+ // sits immediately before the stem, so `ומשוער` ("and projected") must
903
+ // meet the mem floor at n=2 exactly as `משוער` does at n=1.
904
+ const minStem =
905
+ token[n - 1] === "מ"
906
+ ? EPISODE_MIN_STEM_CHARS_AFTER_MEM
907
+ : EPISODE_MIN_STEM_CHARS;
908
+ // `break`, not `continue`: stems only shrink, so nothing after a
909
+ // failure can clear a floor this one missed.
910
+ if (stem.length < minStem) break;
911
+ words.add(stem);
912
+ }
913
+ }
914
+ return words;
915
+ }
916
+
917
+ /** One `<episode>` element: the note's identity, then whatever body it is given. */
918
+ function renderEpisodeEntry(ep: EpisodeMeta, body: string): string {
919
+ return ` <episode title="${escapeXmlAttr(ep.title)}" status="${ep.status}" last_mentioned="${ep.lastMentioned}">\n${escapeXmlText(body)}\n </episode>`;
920
+ }
921
+
922
+ /**
923
+ * The body of a cut note: the summary, the leading `keep` lines of
924
+ * `## Current State`, and — when anything was left out — a marker naming the
925
+ * file, so the model can open the note for the rest.
926
+ *
927
+ * The marker is the whole point. `AGENTS.md` already tells the reporter to
928
+ * open the topic note when it did not arrive inside the message, but a note
929
+ * that arrives *silently truncated* looks complete: a cap the reader cannot
930
+ * see is a trap, not a cap.
931
+ */
932
+ function cutEpisodeBody(
933
+ ep: EpisodeMeta,
934
+ stateLines: string[],
935
+ keep: number,
936
+ file: string,
937
+ ): string {
938
+ const parts = [ep.summary];
939
+ if (keep > 0) parts.push(stateLines.slice(0, keep).join("\n"));
940
+ const dropped = stateLines.length - keep;
941
+ if (dropped > 0)
942
+ parts.push(
943
+ `[cut: ${dropped} more lines in knowledge/episodes/${file} — read the file for the rest]`,
944
+ );
945
+ return parts.join("\n");
946
+ }
947
+
948
+ /**
949
+ * A single note that alone exceeds the per-turn budget, cut to the leading
950
+ * whole lines of its `## Current State` that fit.
951
+ *
952
+ * Leading lines and not a character slice: the distiller writes one fact per
953
+ * line with its source and date, and half a fact — a line without its
954
+ * `(source, date)` tail — is worse than no fact.
955
+ *
956
+ * The marker's length depends on the number dropped, so each candidate is
957
+ * measured whole rather than budgeted for in advance. When even the summary
958
+ * plus the marker overflows (a pathological one-line summary), that smallest
959
+ * form is returned anyway — there is nothing smaller to fall back to, and the
960
+ * caller's own guard still bounds every note after the first.
961
+ */
962
+ function cutEpisodeEntry(
963
+ ep: EpisodeMeta,
964
+ file: string,
965
+ maxChars: number,
966
+ ): { entry: string; dropped: number } {
967
+ const stateLines = ep.currentState ? ep.currentState.split("\n") : [];
968
+ let best = renderEpisodeEntry(ep, cutEpisodeBody(ep, stateLines, 0, file));
969
+ let kept = 0;
970
+ for (let keep = 1; keep <= stateLines.length; keep++) {
971
+ const candidate = renderEpisodeEntry(
972
+ ep,
973
+ cutEpisodeBody(ep, stateLines, keep, file),
974
+ );
975
+ if (candidate.length > maxChars) break;
976
+ best = candidate;
977
+ kept = keep;
978
+ }
979
+ return { entry: best, dropped: stateLines.length - kept };
980
+ }
981
+
768
982
  function buildEpisodeContext(
769
983
  spaceWorkspace: string,
770
984
  userPrompt: string,
@@ -780,21 +994,16 @@ function buildEpisodeContext(
780
994
  }
781
995
  if (files.length === 0) return null;
782
996
 
783
- const messageWords = new Set(
784
- userPrompt
785
- .toLowerCase()
786
- .split(/[\s,.!?;:'"()[\]{}<>]+/)
787
- .filter((w) => w.length > 1),
788
- );
997
+ const messageWords = episodeMessageWords(userPrompt);
789
998
 
790
- const scored: Array<{ ep: EpisodeMeta; score: number }> = [];
999
+ const scored: Array<{ ep: EpisodeMeta; file: string; score: number }> = [];
791
1000
  for (const file of files) {
792
1001
  try {
793
1002
  const content = readFileSync(path.join(episodesDir, file), "utf8");
794
1003
  const ep = parseEpisodeFrontmatter(content);
795
1004
  if (!ep) continue;
796
1005
  const score = scoreEpisode(ep, messageWords);
797
- if (score > 0) scored.push({ ep, score });
1006
+ if (score > 0) scored.push({ ep, file, score });
798
1007
  } catch {
799
1008
  // skip malformed episode files
800
1009
  }
@@ -804,15 +1013,39 @@ function buildEpisodeContext(
804
1013
 
805
1014
  scored.sort((a, b) => b.score - a.score);
806
1015
 
1016
+ // 3,200 UTF-16 units, not tokens: ≈800 English tokens, ≈1,100–1,300 Hebrew
1017
+ // ones, which is the side this profile is written for.
807
1018
  const maxChars = EPISODE_TOKEN_BUDGET * APPROX_CHARS_PER_TOKEN;
808
1019
  const entries: string[] = [];
809
1020
  let totalChars = 0;
810
1021
 
811
- for (const { ep } of scored) {
1022
+ for (const { ep, file } of scored) {
812
1023
  const body = ep.currentState
813
1024
  ? `${ep.summary}\n${ep.currentState}`
814
1025
  : ep.summary;
815
- const entry = ` <episode title="${escapeXmlAttr(ep.title)}" status="${ep.status}" last_mentioned="${ep.lastMentioned}">\n${escapeXmlText(body)}\n </episode>`;
1026
+ let entry = renderEpisodeEntry(ep, body);
1027
+ if (entry.length > maxChars) {
1028
+ // One note bigger than the whole budget. The loop below only bounds the
1029
+ // *number* of episodes and exempts the first, so before this cut an
1030
+ // oversized note passed whole and unmarked — 11 KB of Barcelona in a
1031
+ // 3,200-char budget.
1032
+ const cut = cutEpisodeEntry(ep, file, maxChars);
1033
+ entry = cut.entry;
1034
+ if (cut.dropped > 0)
1035
+ logTiming("episode_cut", {
1036
+ file,
1037
+ dropped: cut.dropped,
1038
+ kept_chars: entry.length,
1039
+ });
1040
+ // Nothing to drop and still over: a note whose one-line summary alone
1041
+ // busts the budget. Say that, rather than claim a cut that did not
1042
+ // happen.
1043
+ else if (entry.length > maxChars)
1044
+ logTiming("episode_over_budget_uncuttable", {
1045
+ file,
1046
+ chars: entry.length,
1047
+ });
1048
+ }
816
1049
  if (totalChars + entry.length > maxChars && entries.length > 0) break;
817
1050
  entries.push(entry);
818
1051
  totalChars += entry.length;
@@ -888,8 +1121,29 @@ export function buildHistoryXml(messages: StoredMessage[]): string | null {
888
1121
  : "";
889
1122
  const entry = ` <turn timestamp="${ts}">\n${userLine}${assistantLine}\n </turn>`;
890
1123
 
891
- if (usedChars + entry.length > HISTORY_CHAR_BUDGET && entries.length > 0)
1124
+ if (usedChars + entry.length > HISTORY_CHAR_BUDGET && entries.length > 0) {
1125
+ // Backstop, not the primary lever: the host clips each message to
1126
+ // `context.history_max_chars` before the window ever gets here. At the
1127
+ // default window of 10 turns that is 10 x 2 rows x 2,000 chars = 40,000,
1128
+ // a tenth of this budget; at the maximum window of 50 it is 200,000, half
1129
+ // of it, and that is before per-turn XML overhead and the inflation
1130
+ // `escapeXmlText` adds. So this branch is out of reach at the defaults
1131
+ // and reachable for an operator who widened the window or raised the cap
1132
+ // — and dropping whole turns silently is the failure mode the cap was
1133
+ // written to avoid, so say so.
1134
+ //
1135
+ // Both sinks, on purpose. The stderr line is what a `docker logs` on a
1136
+ // live container shows; the trace line is the one the host keeps, because
1137
+ // the container is detached and `--rm` and nothing parses its stderr.
1138
+ const dropped = {
1139
+ droppedTurns: i + 1,
1140
+ keptTurns: entries.length,
1141
+ budgetChars: HISTORY_CHAR_BUDGET,
1142
+ };
1143
+ logTiming("history_budget_dropped_turns", dropped);
1144
+ traceEvent("history_budget_dropped_turns", dropped);
892
1145
  break;
1146
+ }
893
1147
  usedChars += entry.length;
894
1148
  entries.unshift(entry);
895
1149
  }
@@ -1185,6 +1439,22 @@ function buildBwrapArgs(
1185
1439
  return args;
1186
1440
  }
1187
1441
 
1442
+ /**
1443
+ * Whether Mercury owns the whole system prompt (`--system-prompt`) rather than
1444
+ * appending to pi's own (`--append-system-prompt`).
1445
+ *
1446
+ * **Absence means override.** The host always sets `OVERRIDE_PI_SYSTEM_PROMPT`
1447
+ * explicitly (`container-runner.ts` writes it as literal "true"/"false" on
1448
+ * every spawn), so a missing value is unreachable in production. It is still
1449
+ * written to deny rather than allow: the mode absence used to select — append —
1450
+ * is the one Anthropic's OAuth gate rejects outright, so an unset var would
1451
+ * have silently produced a bot that answers nothing, with a 400 that names
1452
+ * billing. Only an explicit "false" or "0" turns the override off.
1453
+ */
1454
+ export function shouldOverridePiSystemPrompt(raw: string | undefined): boolean {
1455
+ return raw !== "false" && raw !== "0";
1456
+ }
1457
+
1188
1458
  function invokePiOnce(
1189
1459
  payload: Payload,
1190
1460
  provider: string,
@@ -1192,9 +1462,9 @@ function invokePiOnce(
1192
1462
  capabilities: ModelCapabilities,
1193
1463
  ): Promise<PiJsonlParseResult> {
1194
1464
  return new Promise((resolve, reject) => {
1195
- const overridePiPrompt =
1196
- process.env.OVERRIDE_PI_SYSTEM_PROMPT === "true" ||
1197
- process.env.OVERRIDE_PI_SYSTEM_PROMPT === "1";
1465
+ const overridePiPrompt = shouldOverridePiSystemPrompt(
1466
+ process.env.OVERRIDE_PI_SYSTEM_PROMPT,
1467
+ );
1198
1468
 
1199
1469
  // Combine base system prompt with extension-injected fragments
1200
1470
  let systemPrompt = buildSystemPrompt(
@@ -7,8 +7,9 @@
7
7
  * extension declares via `mercury.env()` avoid this — they are injected in
8
8
  * runtime.ts behind the extension's permission gate instead.
9
9
  *
10
- * `containerEnvPassthrough: "claimed"` narrows the blunt path to nothing, so
11
- * declared vars are the only way in.
10
+ * `containerEnvPassthrough: "claimed"` — the default — narrows the blunt path
11
+ * to the model-provider list, so declared vars are the only other way in.
12
+ * `"all"` is the explicit escape hatch back to blind passthrough.
12
13
  */
13
14
 
14
15
  /** Vars that must never reach a container, regardless of passthrough mode. */
@@ -71,6 +72,19 @@ export const BLOCKED_ENV_VARS = new Set([
71
72
  * test), plus MERCURY_ANTHROPIC_OAUTH_TOKEN, which console provisioning sets
72
73
  * instead of an API key.
73
74
  */
75
+ /**
76
+ * Container switches that pass in both modes. Not secrets — they tell
77
+ * container-entry which sandbox shape to run — and read there under the
78
+ * stripped name (`DISABLE_BUBBLEWRAP`), which only blind passthrough ever
79
+ * delivered: flipping the default to `claimed` silently withdrew the
80
+ * documented last-resort escape hatch, and the container went back to
81
+ * spawning bwrap on a host where bwrap cannot start. Keep this set finite
82
+ * and literal, like BLOCKED_ENV_VARS.
83
+ */
84
+ export const CONTAINER_SWITCH_ENV_VARS = new Set([
85
+ "MERCURY_DISABLE_BUBBLEWRAP",
86
+ ]);
87
+
74
88
  export const MODEL_PROVIDER_ENV_VARS = new Set([
75
89
  "MERCURY_AI_GATEWAY_API_KEY",
76
90
  "MERCURY_ANTHROPIC_API_KEY",
@@ -118,16 +132,18 @@ export function listUnclaimedPassthroughVars(
118
132
  }
119
133
 
120
134
  /**
121
- * Undeclared vars that are not model-provider plumbing — the ones worth
122
- * surfacing at startup. Provider keys are expected to be here and would only
123
- * add noise; a var in this list is one nobody scoped to anything.
135
+ * Undeclared vars that are neither model-provider plumbing nor a container
136
+ * switch — the ones worth surfacing at startup. Provider keys and the sandbox
137
+ * hatch pass in both modes, so naming them as "withheld" would be a lie; a var
138
+ * in this list is one nobody scoped to anything.
124
139
  */
125
140
  export function listUnexpectedPassthroughVars(
126
141
  env: NodeJS.ProcessEnv,
127
142
  claimed: Set<string> | undefined,
128
143
  ): string[] {
129
144
  return listUnclaimedPassthroughVars(env, claimed).filter(
130
- (key) => !MODEL_PROVIDER_ENV_VARS.has(key),
145
+ (key) =>
146
+ !MODEL_PROVIDER_ENV_VARS.has(key) && !CONTAINER_SWITCH_ENV_VARS.has(key),
131
147
  );
132
148
  }
133
149
 
@@ -145,8 +161,10 @@ export function selectPassthroughEnv(
145
161
  ): Array<{ key: string; value: string }> {
146
162
  const names =
147
163
  mode === "claimed"
148
- ? listUnclaimedPassthroughVars(env, claimed).filter((key) =>
149
- MODEL_PROVIDER_ENV_VARS.has(key),
164
+ ? listUnclaimedPassthroughVars(env, claimed).filter(
165
+ (key) =>
166
+ MODEL_PROVIDER_ENV_VARS.has(key) ||
167
+ CONTAINER_SWITCH_ENV_VARS.has(key),
150
168
  )
151
169
  : listUnclaimedPassthroughVars(env, claimed);
152
170