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.
- package/container/Dockerfile +25 -0
- package/container/build.sh +23 -3
- package/docs/behavior-layers.md +25 -14
- package/docs/configuration.md +139 -11
- package/docs/container-lifecycle.md +151 -1
- package/docs/context-architecture.md +6 -2
- package/docs/extensions.md +9 -1
- package/docs/goals/football-reporter-profile/decisions.md +79 -0
- package/docs/goals/rehearsal-bench/decisions.md +41 -3
- package/docs/goals/rehearsal-bench/roadmap.md +3 -1
- package/docs/goals/release-gate/decisions.md +86 -0
- package/docs/goals/release-gate/roadmap.md +36 -3
- package/docs/live-testing.md +22 -0
- package/docs/pending-verification.md +178 -0
- package/docs/permissions.md +1 -1
- package/docs/profile-guide.md +28 -8
- package/examples/extensions/archive/backends/local.ts +6 -3
- package/examples/extensions/archive/queue.ts +51 -10
- package/examples/extensions/feed-watch/config.ts +42 -2
- package/examples/extensions/feed-watch/digest.ts +4 -7
- package/examples/extensions/feed-watch/items.ts +18 -7
- package/examples/extensions/feed-watch/match.ts +125 -9
- package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
- package/examples/extensions/feed-watch/watch.ts +39 -5
- package/examples/extensions/gws/index.ts +126 -8
- package/examples/extensions/longview/hook.ts +72 -7
- package/examples/extensions/longview/index.ts +2 -0
- package/examples/extensions/morning/README.md +26 -15
- package/examples/extensions/morning/index.ts +29 -6
- package/examples/extensions/morning/lib/hosts.ts +16 -0
- package/examples/extensions/morning/lib/morning.ts +78 -0
- package/examples/extensions/morning/lib/upload.ts +584 -0
- package/examples/extensions/morning/skill/SKILL.md +34 -4
- package/examples/extensions/napkin/index.ts +12 -3
- package/examples/extensions/napkin/pi-spawn.ts +5 -1
- package/examples/extensions/overview/index.ts +20 -0
- package/examples/extensions/overview/skill/SKILL.md +9 -1
- package/examples/extensions/pinchtab/index.ts +36 -7
- package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
- package/examples/profiles/_template/AGENTS.md +6 -1
- package/examples/profiles/football-reporter/AGENTS.md +16 -4
- package/examples/profiles/football-reporter/README.md +1 -1
- package/examples/profiles/football-reporter/config.yaml +42 -6
- package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
- package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
- package/examples/profiles/football-reporter/standard.json +45 -8
- package/package.json +11 -6
- package/resources/skills/tasks/SKILL.md +17 -1
- package/resources/templates/AGENTS.md +4 -3
- package/resources/templates/mercury.example.yaml +13 -4
- package/src/adapters/whatsapp-ingress.ts +15 -3
- package/src/agent/container-entry.ts +299 -29
- package/src/agent/container-env.ts +26 -8
- package/src/agent/container-runner.ts +497 -76
- package/src/agent/image-contract.ts +77 -0
- package/src/agent/image-manifest.ts +180 -0
- package/src/agent/image-refresh.ts +318 -0
- package/src/cli/mercury.ts +225 -28
- package/src/cli/mrctl-http.ts +5 -0
- package/src/cli/mrctl.ts +57 -5
- package/src/cli/service-unit.ts +109 -0
- package/src/config-file.ts +22 -1
- package/src/config.ts +105 -14
- package/src/core/api.ts +11 -2
- package/src/core/commands.ts +11 -4
- package/src/core/connection-health.ts +377 -0
- package/src/core/direct-send.ts +235 -14
- package/src/core/exec.ts +10 -0
- package/src/core/history-window.ts +142 -0
- package/src/core/model-command.ts +130 -0
- package/src/core/operator-alerts.ts +326 -23
- package/src/core/permissions.ts +28 -0
- package/src/core/profiles.ts +32 -16
- package/src/core/reply-context.ts +31 -0
- package/src/core/routes/config-builtin.ts +11 -0
- package/src/core/routes/console.ts +98 -16
- package/src/core/routes/dashboard.ts +93 -10
- package/src/core/routes/model.ts +26 -3
- package/src/core/routes/send.ts +1 -1
- package/src/core/routes/tasks.ts +74 -0
- package/src/core/runtime.ts +302 -21
- package/src/core/system-messages.ts +33 -0
- package/src/core/task-scheduler.ts +155 -7
- package/src/extensions/image-builder.ts +1 -1
- package/src/extensions/installer.ts +72 -17
- package/src/extensions/load-project.ts +74 -0
- package/src/extensions/loader.ts +50 -9
- package/src/host-version.ts +32 -0
- package/src/main.ts +42 -21
- package/src/preflight/checks/credential.ts +300 -0
- package/src/preflight/checks/docker.ts +149 -0
- package/src/preflight/checks/extensions.ts +90 -0
- package/src/preflight/checks/host-deps.ts +247 -0
- package/src/preflight/checks/image-contract.ts +228 -0
- package/src/preflight/checks/roundtrip.ts +424 -0
- package/src/preflight/checks/sandbox.ts +159 -0
- package/src/preflight/deps.ts +107 -0
- package/src/preflight/probe-container.ts +156 -0
- package/src/preflight/report.ts +177 -0
- package/src/preflight/run.ts +223 -0
- package/src/server.ts +55 -14
- package/src/storage/db.ts +41 -2
- package/src/storage/models-json.ts +110 -0
- package/src/text/reporter-lint.ts +167 -23
- package/src/types.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercury-agent",
|
|
3
|
-
"version": "0.
|
|
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": "
|
|
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
|
|
59
|
-
mrctl model switch <N|MODEL_ID> #
|
|
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.
|
|
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. "
|
|
75
|
-
# #
|
|
76
|
-
# #
|
|
77
|
-
#
|
|
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.
|
|
110
|
-
*
|
|
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
|
|
121
|
+
return value
|
|
122
|
+
.replace(/&/g, "&")
|
|
123
|
+
.replace(/</g, "<")
|
|
124
|
+
.replace(/>/g, ">")
|
|
125
|
+
.replace(/"/g, """);
|
|
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
|
-
|
|
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
|
|
501
|
-
//
|
|
502
|
-
//
|
|
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=
|
|
519
|
-
*
|
|
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
|
-
|
|
626
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1197
|
-
|
|
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
|
|
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
|
|
122
|
-
* surfacing at startup. Provider keys
|
|
123
|
-
*
|
|
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) =>
|
|
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(
|
|
149
|
-
|
|
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
|
|