mercury-agent 0.4.22 → 0.4.24

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 (60) hide show
  1. package/docs/ARCHITECTURE.md +30 -0
  2. package/docs/DESIGN.md +42 -0
  3. package/docs/ROADMAP.md +41 -0
  4. package/docs/VISION.md +32 -0
  5. package/docs/archive/.gitkeep +0 -0
  6. package/docs/backlog/.gitkeep +0 -0
  7. package/docs/bugs/.gitkeep +0 -0
  8. package/docs/bugs/bwrap-privileged-linux-docker.md +140 -0
  9. package/docs/bugs/registry-pull-no-local-fallback.md +168 -0
  10. package/docs/debug/major/.gitkeep +0 -0
  11. package/docs/debug/minor/.gitkeep +0 -0
  12. package/docs/debug/moderate/.gitkeep +0 -0
  13. package/docs/html-slides/.gitkeep +0 -0
  14. package/docs/ideas/.gitkeep +0 -0
  15. package/docs/in-progress/.gitkeep +0 -0
  16. package/docs/notes/.gitkeep +0 -0
  17. package/docs/pending-updates/.gitkeep +0 -0
  18. package/docs/runbooks/publish-checklist.md +8 -0
  19. package/docs/templates/TEMPLATE-AUTOPILOT-CONFIG.yaml +35 -0
  20. package/docs/templates/TEMPLATE-BUG.md +71 -0
  21. package/docs/templates/TEMPLATE-CI.yml +25 -0
  22. package/docs/templates/TEMPLATE-DECISIONS.md +11 -0
  23. package/docs/templates/TEMPLATE-FEATURE.md +127 -0
  24. package/docs/templates/TEMPLATE-GOAL.md +31 -0
  25. package/docs/templates/TEMPLATE-IDEA.md +31 -0
  26. package/docs/templates/TEMPLATE-NOTE.md +27 -0
  27. package/docs/templates/TEMPLATE-ROADMAP.md +21 -0
  28. package/examples/extensions/pinchtab/index.ts +1 -1
  29. package/package.json +1 -1
  30. package/src/adapters/whatsapp.ts +635 -635
  31. package/src/agent/container-entry.ts +66 -12
  32. package/src/agent/container-runner.ts +18 -12
  33. package/src/agent/model-capabilities.ts +231 -231
  34. package/src/bridges/discord.ts +178 -178
  35. package/src/bridges/slack.ts +179 -179
  36. package/src/bridges/teams.ts +162 -162
  37. package/src/bridges/telegram.ts +579 -579
  38. package/src/cli/mercury.ts +2587 -2585
  39. package/src/cli/whatsapp-auth.ts +263 -263
  40. package/src/config.ts +316 -316
  41. package/src/core/commands.ts +110 -110
  42. package/src/core/permissions.ts +196 -196
  43. package/src/core/router.ts +204 -204
  44. package/src/core/routes/chat.ts +175 -175
  45. package/src/core/routes/dashboard.ts +2493 -2493
  46. package/src/core/routes/messages.ts +37 -37
  47. package/src/core/routes/mutes.ts +95 -95
  48. package/src/core/routes/roles.ts +135 -135
  49. package/src/core/runtime.ts +1508 -1508
  50. package/src/core/task-scheduler.ts +139 -139
  51. package/src/extensions/catalog.ts +117 -117
  52. package/src/extensions/hooks.ts +161 -161
  53. package/src/extensions/installer.ts +306 -306
  54. package/src/extensions/loader.ts +271 -271
  55. package/src/extensions/permission-guard.ts +52 -52
  56. package/src/server.ts +391 -391
  57. package/src/storage/db.ts +1684 -1687
  58. package/src/storage/pi-auth.ts +95 -95
  59. package/src/tts/azure.ts +52 -52
  60. package/src/tts/synthesize.ts +133 -133
@@ -6,6 +6,7 @@ import {
6
6
  readdirSync,
7
7
  readFileSync,
8
8
  renameSync,
9
+ unlinkSync,
9
10
  writeFileSync,
10
11
  } from "node:fs";
11
12
  import path from "node:path";
@@ -530,6 +531,8 @@ function buildEpisodeContext(
530
531
  return `<active_episodes>\n${entries.join("\n")}\n</active_episodes>`;
531
532
  }
532
533
 
534
+ const HISTORY_CHAR_BUDGET = 400_000;
535
+
533
536
  function buildHistoryXml(messages: StoredMessage[]): string | null {
534
537
  // Pair up user+assistant turns; skip ambient (they have their own section)
535
538
  const turns: Array<{ user: StoredMessage; assistant?: StoredMessage }> = [];
@@ -538,7 +541,6 @@ function buildHistoryXml(messages: StoredMessage[]): string | null {
538
541
  for (const m of messages) {
539
542
  if (m.role === "user") {
540
543
  if (pendingUser) {
541
- // user without assistant reply (shouldn't normally happen, but include it)
542
544
  turns.push({ user: pendingUser });
543
545
  }
544
546
  pendingUser = m;
@@ -547,20 +549,32 @@ function buildHistoryXml(messages: StoredMessage[]): string | null {
547
549
  pendingUser = null;
548
550
  }
549
551
  }
550
- // Any trailing user message without a reply
551
552
  if (pendingUser) turns.push({ user: pendingUser });
552
553
 
553
554
  if (turns.length === 0) return null;
554
555
 
555
- const entries = turns.map(({ user, assistant }) => {
556
+ // Build newest-first, stop when budget exhausted
557
+ const entries: string[] = [];
558
+ let usedChars = 0;
559
+
560
+ for (let i = turns.length - 1; i >= 0; i--) {
561
+ const turn = turns[i];
562
+ if (!turn) break;
563
+ const { user, assistant } = turn;
556
564
  const ts = formatContextTimestamp(user.createdAt);
557
565
  const userLine = ` <user>${escapeXmlText(user.content)}</user>`;
558
566
  const assistantLine = assistant
559
567
  ? `\n <assistant>${escapeXmlText(assistant.content)}</assistant>`
560
568
  : "";
561
- return ` <turn timestamp="${ts}">\n${userLine}${assistantLine}\n </turn>`;
562
- });
569
+ const entry = ` <turn timestamp="${ts}">\n${userLine}${assistantLine}\n </turn>`;
563
570
 
571
+ if (usedChars + entry.length > HISTORY_CHAR_BUDGET && entries.length > 0)
572
+ break;
573
+ usedChars += entry.length;
574
+ entries.unshift(entry);
575
+ }
576
+
577
+ if (entries.length === 0) return null;
564
578
  return `<history>\n${entries.join("\n")}\n</history>`;
565
579
  }
566
580
 
@@ -659,7 +673,11 @@ function buildPrompt(payload: Payload): string {
659
673
  * Uses bubblewrap for defense-in-depth: Docker isolates from host, bwrap restricts within container.
660
674
  * See https://github.com/containers/bubblewrap
661
675
  */
662
- function buildBwrapArgs(workspace: string, command: string[]): string[] {
676
+ function buildBwrapArgs(
677
+ workspace: string,
678
+ command: string[],
679
+ ioDir?: string,
680
+ ): string[] {
663
681
  const args: string[] = [
664
682
  "--ro-bind",
665
683
  "/usr",
@@ -695,6 +713,7 @@ function buildBwrapArgs(workspace: string, command: string[]): string[] {
695
713
  "/dev",
696
714
  "--tmpfs",
697
715
  "/tmp",
716
+ ...(ioDir ? ["--ro-bind", ioDir, ioDir] : []),
698
717
  "--unshare-pid",
699
718
  "--new-session",
700
719
  "--die-with-parent",
@@ -738,6 +757,22 @@ function invokePiOnce(
738
757
  ? "--system-prompt"
739
758
  : "--append-system-prompt";
740
759
 
760
+ const userPrompt = buildPrompt(payload);
761
+
762
+ // E2BIG fix: deliver large prompts via file + stdin instead of argv.
763
+ // System prompt → temp file in IO_DIR (pi's resolvePromptInput reads files).
764
+ // User prompt → stdin pipe (pi's readPipedStdin reads when !isTTY).
765
+ const ioDir = process.env.IO_DIR;
766
+ let systemPromptArg: string;
767
+ let systemPromptFile: string | undefined;
768
+ if (ioDir) {
769
+ systemPromptFile = path.join(ioDir, "system-prompt.txt");
770
+ writeFileSync(systemPromptFile, systemPrompt);
771
+ systemPromptArg = systemPromptFile;
772
+ } else {
773
+ systemPromptArg = systemPrompt;
774
+ }
775
+
741
776
  const piArgs = [
742
777
  "--print",
743
778
  "--mode",
@@ -754,8 +789,7 @@ function invokePiOnce(
754
789
  "-e",
755
790
  "/app/resources/pi-extensions/subagent/index.ts",
756
791
  systemPromptFlag,
757
- systemPrompt,
758
- buildPrompt(payload),
792
+ systemPromptArg,
759
793
  ];
760
794
 
761
795
  // gVisor (runsc) provides stronger syscall-level isolation than bwrap; skip bwrap when active.
@@ -777,21 +811,25 @@ function invokePiOnce(
777
811
  let proc: ReturnType<typeof spawn>;
778
812
  if (useBubblewrap) {
779
813
  const bwrapArgs = [
780
- ...buildBwrapArgs(payload.spaceWorkspace, ["pi"]),
814
+ ...buildBwrapArgs(payload.spaceWorkspace, ["pi"], ioDir),
781
815
  ...piArgs,
782
816
  ];
783
817
  proc = spawn("bwrap", bwrapArgs, {
784
- stdio: ["ignore", "pipe", "pipe"],
818
+ stdio: ["pipe", "pipe", "pipe"],
785
819
  env: process.env,
786
820
  });
787
821
  } else {
788
822
  proc = spawn("pi", piArgs, {
789
823
  cwd: payload.spaceWorkspace,
790
- stdio: ["ignore", "pipe", "pipe"],
824
+ stdio: ["pipe", "pipe", "pipe"],
791
825
  env: process.env,
792
826
  });
793
827
  }
794
828
 
829
+ // Pipe user prompt via stdin — pi's readPipedStdin() reads this when !isTTY.
830
+ proc.stdin?.on("error", () => {});
831
+ proc.stdin?.end(userPrompt, "utf8");
832
+
795
833
  let stdout = "";
796
834
  let stderr = "";
797
835
  let piFirstOutputAt: number | null = null;
@@ -810,9 +848,25 @@ function invokePiOnce(
810
848
  stderr += chunk.toString("utf8");
811
849
  });
812
850
 
813
- proc.on("error", (error) => reject(error));
851
+ proc.on("error", (error) => {
852
+ if (systemPromptFile) {
853
+ try {
854
+ unlinkSync(systemPromptFile);
855
+ } catch (e) {
856
+ process.stderr.write(`system-prompt cleanup: ${e}\n`);
857
+ }
858
+ }
859
+ reject(error);
860
+ });
814
861
 
815
862
  proc.on("close", (code) => {
863
+ if (systemPromptFile) {
864
+ try {
865
+ unlinkSync(systemPromptFile);
866
+ } catch (e) {
867
+ process.stderr.write(`system-prompt cleanup: ${e}\n`);
868
+ }
869
+ }
816
870
  logTiming("container.pi.done", {
817
871
  piDurationMs: Date.now() - piSpawnedAt,
818
872
  exitCode: code ?? null,
@@ -978,18 +978,24 @@ export class AgentContainerRunner {
978
978
  "CONTAINER_RUNTIME=runsc",
979
979
  );
980
980
  } else if (this.config.containerBwrapDockerCompat || isDockerDesktop()) {
981
- logger.info("Enabling bwrap Docker compat (seccomp/apparmor/SYS_ADMIN)", {
982
- configFlag: this.config.containerBwrapDockerCompat,
983
- dockerDesktop: isDockerDesktop(),
984
- });
985
- args.push(
986
- "--security-opt",
987
- "seccomp=unconfined",
988
- "--security-opt",
989
- "apparmor=unconfined",
990
- "--cap-add",
991
- "SYS_ADMIN",
992
- );
981
+ if (this.config.containerBwrapDockerCompat) {
982
+ logger.info(
983
+ "Enabling bwrap Docker compat with --privileged (containerBwrapDockerCompat=true)",
984
+ );
985
+ args.push("--privileged");
986
+ } else {
987
+ logger.info(
988
+ "Enabling bwrap Docker compat (seccomp/apparmor/SYS_ADMIN) for Docker Desktop",
989
+ );
990
+ args.push(
991
+ "--security-opt",
992
+ "seccomp=unconfined",
993
+ "--security-opt",
994
+ "apparmor=unconfined",
995
+ "--cap-add",
996
+ "SYS_ADMIN",
997
+ );
998
+ }
993
999
  }
994
1000
 
995
1001
  for (const { key, value } of envPairs) {
@@ -1,231 +1,231 @@
1
- /**
2
- * Model capability flags — used to adapt prompts, pi tool flags, and skill installation.
3
- */
4
-
5
- import { existsSync, readFileSync } from "node:fs";
6
- import path from "node:path";
7
- import { getModels, type KnownProvider } from "@earendil-works/pi-ai";
8
- import { parse as parseYaml } from "yaml";
9
- import { z } from "zod";
10
- import type { ModelLeg } from "../config.js";
11
- import {
12
- DEFAULT_CAPABILITIES,
13
- type ModelCapabilities,
14
- type ModelCapabilityKey,
15
- } from "./model-capabilities-core.js";
16
-
17
- export type {
18
- ModelCapabilities,
19
- ModelCapabilityKey,
20
- } from "./model-capabilities-core.js";
21
- export { DEFAULT_CAPABILITIES } from "./model-capabilities-core.js";
22
-
23
- export type CapabilityResolveSource = "env" | "yaml" | "builtin" | "default";
24
-
25
- export type ResolvedModelCapabilities = {
26
- capabilities: ModelCapabilities;
27
- source: CapabilityResolveSource;
28
- };
29
-
30
- function mergePartialCapabilities(
31
- partial: Partial<Record<ModelCapabilityKey, unknown>>,
32
- ): ModelCapabilities {
33
- const out = { ...DEFAULT_CAPABILITIES };
34
- for (const k of Object.keys(partial) as ModelCapabilityKey[]) {
35
- const v = partial[k];
36
- if (typeof v === "boolean") out[k] = v;
37
- }
38
- return out;
39
- }
40
-
41
- const partialCapsSchema = z.object({
42
- tools: z.boolean().optional(),
43
- vision: z.boolean().optional(),
44
- audio_input: z.boolean().optional(),
45
- audio_output: z.boolean().optional(),
46
- extended_thinking: z.boolean().optional(),
47
- });
48
-
49
- const yamlFileSchema = z.object({
50
- models: z.record(z.string(), partialCapsSchema),
51
- });
52
-
53
- export type UserModelCapabilitiesMap = Map<string, ModelCapabilities>;
54
-
55
- /** Path to optional user overrides: `<dataDir>/model-capabilities.yaml` */
56
- export function modelCapabilitiesYamlPath(dataDirAbsolute: string): string {
57
- return path.join(dataDirAbsolute, "model-capabilities.yaml");
58
- }
59
-
60
- /**
61
- * Load `.mercury/model-capabilities.yaml` (under resolved data dir).
62
- * Returns null if missing or invalid (callers may log).
63
- */
64
- export function loadUserModelCapabilitiesMap(
65
- dataDirAbsolute: string,
66
- ): UserModelCapabilitiesMap | null {
67
- const yamlPath = modelCapabilitiesYamlPath(dataDirAbsolute);
68
- if (!existsSync(yamlPath)) return null;
69
- try {
70
- const raw = readFileSync(yamlPath, "utf8");
71
- const doc = parseYaml(raw) as unknown;
72
- const parsed = yamlFileSchema.safeParse(doc);
73
- if (!parsed.success) return null;
74
- const map = new Map<string, ModelCapabilities>();
75
- for (const [modelId, partial] of Object.entries(parsed.data.models)) {
76
- map.set(modelId.trim(), mergePartialCapabilities(partial));
77
- }
78
- return map;
79
- } catch {
80
- return null;
81
- }
82
- }
83
-
84
- /**
85
- * Look up capabilities for a model using pi's MODELS registry as the source of truth.
86
- * Derives: vision (image in input), audio_input (audio in input), extended_thinking (reasoning).
87
- * tools defaults to true — pi has no tools field; all pi-known models support tool use.
88
- * audio_output is not tracked by pi; always false.
89
- */
90
- function matchBuiltinCapabilities(
91
- provider: string,
92
- modelId: string,
93
- ): ModelCapabilities | null {
94
- // Cast to KnownProvider — getModels returns [] for unrecognised providers at runtime
95
- const model = getModels(provider as KnownProvider).find(
96
- (m) => m.id === modelId,
97
- );
98
- if (!model) return null;
99
- return {
100
- tools: true,
101
- vision: model.input.includes("image"),
102
- audio_input: false, // pi types input as ("text"|"image")[]; no audio models exist yet
103
- audio_output: false,
104
- extended_thinking: model.reasoning,
105
- };
106
- }
107
-
108
- /** Parse MERCURY_MODEL_CAPABILITIES JSON; returns null if unset or invalid. */
109
- export function parseModelCapabilitiesEnv(
110
- raw: string | undefined,
111
- ): ModelCapabilities | null {
112
- const trimmed = raw?.trim();
113
- if (!trimmed) return null;
114
- try {
115
- const json = JSON.parse(trimmed) as unknown;
116
- const r = partialCapsSchema.safeParse(json);
117
- if (!r.success) return null;
118
- return mergePartialCapabilities(r.data);
119
- } catch {
120
- return null;
121
- }
122
- }
123
-
124
- /**
125
- * Resolve capabilities for one model id (single leg).
126
- * Priority: env global override → YAML exact model key → pi MODELS lookup → default.
127
- */
128
- export function resolveModelCapabilitiesWithSource(
129
- modelId: string,
130
- provider: string,
131
- userMap: UserModelCapabilitiesMap | null,
132
- envCaps: ModelCapabilities | null,
133
- ): ResolvedModelCapabilities {
134
- if (envCaps) {
135
- return { capabilities: envCaps, source: "env" };
136
- }
137
-
138
- const exact = userMap?.get(modelId.trim());
139
- if (exact) {
140
- return { capabilities: exact, source: "yaml" };
141
- }
142
-
143
- const builtin = matchBuiltinCapabilities(provider, modelId);
144
- if (builtin) {
145
- return { capabilities: builtin, source: "builtin" };
146
- }
147
-
148
- return { capabilities: { ...DEFAULT_CAPABILITIES }, source: "default" };
149
- }
150
-
151
- export function resolveModelCapabilities(
152
- modelId: string,
153
- provider: string,
154
- userMap: UserModelCapabilitiesMap | null,
155
- envCaps: ModelCapabilities | null,
156
- ): ModelCapabilities {
157
- return resolveModelCapabilitiesWithSource(modelId, provider, userMap, envCaps)
158
- .capabilities;
159
- }
160
-
161
- /** Capabilities for each leg in the model chain (same length as `chain`). */
162
- export function resolveModelChainCapabilities(
163
- chain: ModelLeg[],
164
- dataDirAbsolute: string,
165
- envCaps: ModelCapabilities | null,
166
- ): {
167
- chainCaps: ModelCapabilities[];
168
- userMap: UserModelCapabilitiesMap | null;
169
- } {
170
- const userMap = loadUserModelCapabilitiesMap(dataDirAbsolute);
171
- const chainCaps = chain.map((leg) =>
172
- resolveModelCapabilities(leg.model, leg.provider, userMap, envCaps),
173
- );
174
- return { chainCaps, userMap };
175
- }
176
-
177
- export function chainSupportsRequirements(
178
- requires: ModelCapabilityKey[],
179
- chainCaps: ModelCapabilities[],
180
- ): boolean {
181
- if (requires.length === 0) return true;
182
- return chainCaps.some((caps) => requires.every((key) => caps[key] === true));
183
- }
184
-
185
- /** Log warnings for models that fell back to defaults (once per distinct model id). */
186
- export function logUnknownModelCapabilityWarnings(
187
- chain: ModelLeg[],
188
- dataDirAbsolute: string,
189
- envCaps: ModelCapabilities | null,
190
- log: { warn: (msg: string, obj?: Record<string, unknown>) => void },
191
- ): void {
192
- if (envCaps) return;
193
- const userMap = loadUserModelCapabilitiesMap(dataDirAbsolute);
194
- const seen = new Set<string>();
195
-
196
- for (const leg of chain) {
197
- const id = leg.model.trim();
198
- if (seen.has(id)) continue;
199
- seen.add(id);
200
-
201
- const { source } = resolveModelCapabilitiesWithSource(
202
- id,
203
- leg.provider,
204
- userMap,
205
- null,
206
- );
207
- if (source === "default") {
208
- log.warn(
209
- `Model "${id}" not in built-in capability map and not in model-capabilities.yaml; assuming default capabilities (tools=true, vision=false). Set MERCURY_MODEL_CAPABILITIES or add .mercury/model-capabilities.yaml to override.`,
210
- { model: id },
211
- );
212
- }
213
- }
214
- }
215
-
216
- export function logExtensionCapabilityMismatches(
217
- extensions: Array<{ name: string; requires?: ModelCapabilityKey[] }>,
218
- chainCaps: ModelCapabilities[],
219
- log: { warn: (msg: string, obj?: Record<string, unknown>) => void },
220
- ): void {
221
- for (const ext of extensions) {
222
- const req = ext.requires;
223
- if (!req?.length) continue;
224
- if (!chainSupportsRequirements(req, chainCaps)) {
225
- log.warn(
226
- `Extension "${ext.name}" requires ${req.join(", ")} but no model leg in MERCURY_MODEL_CHAIN supports it; extension skills will not be installed.`,
227
- { extension: ext.name, requires: req },
228
- );
229
- }
230
- }
231
- }
1
+ /**
2
+ * Model capability flags — used to adapt prompts, pi tool flags, and skill installation.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { getModels, type KnownProvider } from "@earendil-works/pi-ai";
8
+ import { parse as parseYaml } from "yaml";
9
+ import { z } from "zod";
10
+ import type { ModelLeg } from "../config.js";
11
+ import {
12
+ DEFAULT_CAPABILITIES,
13
+ type ModelCapabilities,
14
+ type ModelCapabilityKey,
15
+ } from "./model-capabilities-core.js";
16
+
17
+ export type {
18
+ ModelCapabilities,
19
+ ModelCapabilityKey,
20
+ } from "./model-capabilities-core.js";
21
+ export { DEFAULT_CAPABILITIES } from "./model-capabilities-core.js";
22
+
23
+ export type CapabilityResolveSource = "env" | "yaml" | "builtin" | "default";
24
+
25
+ export type ResolvedModelCapabilities = {
26
+ capabilities: ModelCapabilities;
27
+ source: CapabilityResolveSource;
28
+ };
29
+
30
+ function mergePartialCapabilities(
31
+ partial: Partial<Record<ModelCapabilityKey, unknown>>,
32
+ ): ModelCapabilities {
33
+ const out = { ...DEFAULT_CAPABILITIES };
34
+ for (const k of Object.keys(partial) as ModelCapabilityKey[]) {
35
+ const v = partial[k];
36
+ if (typeof v === "boolean") out[k] = v;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ const partialCapsSchema = z.object({
42
+ tools: z.boolean().optional(),
43
+ vision: z.boolean().optional(),
44
+ audio_input: z.boolean().optional(),
45
+ audio_output: z.boolean().optional(),
46
+ extended_thinking: z.boolean().optional(),
47
+ });
48
+
49
+ const yamlFileSchema = z.object({
50
+ models: z.record(z.string(), partialCapsSchema),
51
+ });
52
+
53
+ export type UserModelCapabilitiesMap = Map<string, ModelCapabilities>;
54
+
55
+ /** Path to optional user overrides: `<dataDir>/model-capabilities.yaml` */
56
+ export function modelCapabilitiesYamlPath(dataDirAbsolute: string): string {
57
+ return path.join(dataDirAbsolute, "model-capabilities.yaml");
58
+ }
59
+
60
+ /**
61
+ * Load `.mercury/model-capabilities.yaml` (under resolved data dir).
62
+ * Returns null if missing or invalid (callers may log).
63
+ */
64
+ export function loadUserModelCapabilitiesMap(
65
+ dataDirAbsolute: string,
66
+ ): UserModelCapabilitiesMap | null {
67
+ const yamlPath = modelCapabilitiesYamlPath(dataDirAbsolute);
68
+ if (!existsSync(yamlPath)) return null;
69
+ try {
70
+ const raw = readFileSync(yamlPath, "utf8");
71
+ const doc = parseYaml(raw) as unknown;
72
+ const parsed = yamlFileSchema.safeParse(doc);
73
+ if (!parsed.success) return null;
74
+ const map = new Map<string, ModelCapabilities>();
75
+ for (const [modelId, partial] of Object.entries(parsed.data.models)) {
76
+ map.set(modelId.trim(), mergePartialCapabilities(partial));
77
+ }
78
+ return map;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Look up capabilities for a model using pi's MODELS registry as the source of truth.
86
+ * Derives: vision (image in input), audio_input (audio in input), extended_thinking (reasoning).
87
+ * tools defaults to true — pi has no tools field; all pi-known models support tool use.
88
+ * audio_output is not tracked by pi; always false.
89
+ */
90
+ function matchBuiltinCapabilities(
91
+ provider: string,
92
+ modelId: string,
93
+ ): ModelCapabilities | null {
94
+ // Cast to KnownProvider — getModels returns [] for unrecognised providers at runtime
95
+ const model = getModels(provider as KnownProvider).find(
96
+ (m) => m.id === modelId,
97
+ );
98
+ if (!model) return null;
99
+ return {
100
+ tools: true,
101
+ vision: model.input.includes("image"),
102
+ audio_input: false, // pi types input as ("text"|"image")[]; no audio models exist yet
103
+ audio_output: false,
104
+ extended_thinking: model.reasoning,
105
+ };
106
+ }
107
+
108
+ /** Parse MERCURY_MODEL_CAPABILITIES JSON; returns null if unset or invalid. */
109
+ export function parseModelCapabilitiesEnv(
110
+ raw: string | undefined,
111
+ ): ModelCapabilities | null {
112
+ const trimmed = raw?.trim();
113
+ if (!trimmed) return null;
114
+ try {
115
+ const json = JSON.parse(trimmed) as unknown;
116
+ const r = partialCapsSchema.safeParse(json);
117
+ if (!r.success) return null;
118
+ return mergePartialCapabilities(r.data);
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Resolve capabilities for one model id (single leg).
126
+ * Priority: env global override → YAML exact model key → pi MODELS lookup → default.
127
+ */
128
+ export function resolveModelCapabilitiesWithSource(
129
+ modelId: string,
130
+ provider: string,
131
+ userMap: UserModelCapabilitiesMap | null,
132
+ envCaps: ModelCapabilities | null,
133
+ ): ResolvedModelCapabilities {
134
+ if (envCaps) {
135
+ return { capabilities: envCaps, source: "env" };
136
+ }
137
+
138
+ const exact = userMap?.get(modelId.trim());
139
+ if (exact) {
140
+ return { capabilities: exact, source: "yaml" };
141
+ }
142
+
143
+ const builtin = matchBuiltinCapabilities(provider, modelId);
144
+ if (builtin) {
145
+ return { capabilities: builtin, source: "builtin" };
146
+ }
147
+
148
+ return { capabilities: { ...DEFAULT_CAPABILITIES }, source: "default" };
149
+ }
150
+
151
+ export function resolveModelCapabilities(
152
+ modelId: string,
153
+ provider: string,
154
+ userMap: UserModelCapabilitiesMap | null,
155
+ envCaps: ModelCapabilities | null,
156
+ ): ModelCapabilities {
157
+ return resolveModelCapabilitiesWithSource(modelId, provider, userMap, envCaps)
158
+ .capabilities;
159
+ }
160
+
161
+ /** Capabilities for each leg in the model chain (same length as `chain`). */
162
+ export function resolveModelChainCapabilities(
163
+ chain: ModelLeg[],
164
+ dataDirAbsolute: string,
165
+ envCaps: ModelCapabilities | null,
166
+ ): {
167
+ chainCaps: ModelCapabilities[];
168
+ userMap: UserModelCapabilitiesMap | null;
169
+ } {
170
+ const userMap = loadUserModelCapabilitiesMap(dataDirAbsolute);
171
+ const chainCaps = chain.map((leg) =>
172
+ resolveModelCapabilities(leg.model, leg.provider, userMap, envCaps),
173
+ );
174
+ return { chainCaps, userMap };
175
+ }
176
+
177
+ export function chainSupportsRequirements(
178
+ requires: ModelCapabilityKey[],
179
+ chainCaps: ModelCapabilities[],
180
+ ): boolean {
181
+ if (requires.length === 0) return true;
182
+ return chainCaps.some((caps) => requires.every((key) => caps[key] === true));
183
+ }
184
+
185
+ /** Log warnings for models that fell back to defaults (once per distinct model id). */
186
+ export function logUnknownModelCapabilityWarnings(
187
+ chain: ModelLeg[],
188
+ dataDirAbsolute: string,
189
+ envCaps: ModelCapabilities | null,
190
+ log: { warn: (msg: string, obj?: Record<string, unknown>) => void },
191
+ ): void {
192
+ if (envCaps) return;
193
+ const userMap = loadUserModelCapabilitiesMap(dataDirAbsolute);
194
+ const seen = new Set<string>();
195
+
196
+ for (const leg of chain) {
197
+ const id = leg.model.trim();
198
+ if (seen.has(id)) continue;
199
+ seen.add(id);
200
+
201
+ const { source } = resolveModelCapabilitiesWithSource(
202
+ id,
203
+ leg.provider,
204
+ userMap,
205
+ null,
206
+ );
207
+ if (source === "default") {
208
+ log.warn(
209
+ `Model "${id}" not in built-in capability map and not in model-capabilities.yaml; assuming default capabilities (tools=true, vision=false). Set MERCURY_MODEL_CAPABILITIES or add .mercury/model-capabilities.yaml to override.`,
210
+ { model: id },
211
+ );
212
+ }
213
+ }
214
+ }
215
+
216
+ export function logExtensionCapabilityMismatches(
217
+ extensions: Array<{ name: string; requires?: ModelCapabilityKey[] }>,
218
+ chainCaps: ModelCapabilities[],
219
+ log: { warn: (msg: string, obj?: Record<string, unknown>) => void },
220
+ ): void {
221
+ for (const ext of extensions) {
222
+ const req = ext.requires;
223
+ if (!req?.length) continue;
224
+ if (!chainSupportsRequirements(req, chainCaps)) {
225
+ log.warn(
226
+ `Extension "${ext.name}" requires ${req.join(", ")} but no model leg in MERCURY_MODEL_CHAIN supports it; extension skills will not be installed.`,
227
+ { extension: ext.name, requires: req },
228
+ );
229
+ }
230
+ }
231
+ }