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.
- package/docs/ARCHITECTURE.md +30 -0
- package/docs/DESIGN.md +42 -0
- package/docs/ROADMAP.md +41 -0
- package/docs/VISION.md +32 -0
- package/docs/archive/.gitkeep +0 -0
- package/docs/backlog/.gitkeep +0 -0
- package/docs/bugs/.gitkeep +0 -0
- package/docs/bugs/bwrap-privileged-linux-docker.md +140 -0
- package/docs/bugs/registry-pull-no-local-fallback.md +168 -0
- package/docs/debug/major/.gitkeep +0 -0
- package/docs/debug/minor/.gitkeep +0 -0
- package/docs/debug/moderate/.gitkeep +0 -0
- package/docs/html-slides/.gitkeep +0 -0
- package/docs/ideas/.gitkeep +0 -0
- package/docs/in-progress/.gitkeep +0 -0
- package/docs/notes/.gitkeep +0 -0
- package/docs/pending-updates/.gitkeep +0 -0
- package/docs/runbooks/publish-checklist.md +8 -0
- package/docs/templates/TEMPLATE-AUTOPILOT-CONFIG.yaml +35 -0
- package/docs/templates/TEMPLATE-BUG.md +71 -0
- package/docs/templates/TEMPLATE-CI.yml +25 -0
- package/docs/templates/TEMPLATE-DECISIONS.md +11 -0
- package/docs/templates/TEMPLATE-FEATURE.md +127 -0
- package/docs/templates/TEMPLATE-GOAL.md +31 -0
- package/docs/templates/TEMPLATE-IDEA.md +31 -0
- package/docs/templates/TEMPLATE-NOTE.md +27 -0
- package/docs/templates/TEMPLATE-ROADMAP.md +21 -0
- package/examples/extensions/pinchtab/index.ts +1 -1
- package/package.json +1 -1
- package/src/adapters/whatsapp.ts +635 -635
- package/src/agent/container-entry.ts +66 -12
- package/src/agent/container-runner.ts +18 -12
- package/src/agent/model-capabilities.ts +231 -231
- package/src/bridges/discord.ts +178 -178
- package/src/bridges/slack.ts +179 -179
- package/src/bridges/teams.ts +162 -162
- package/src/bridges/telegram.ts +579 -579
- package/src/cli/mercury.ts +2587 -2585
- package/src/cli/whatsapp-auth.ts +263 -263
- package/src/config.ts +316 -316
- package/src/core/commands.ts +110 -110
- package/src/core/permissions.ts +196 -196
- package/src/core/router.ts +204 -204
- package/src/core/routes/chat.ts +175 -175
- package/src/core/routes/dashboard.ts +2493 -2493
- package/src/core/routes/messages.ts +37 -37
- package/src/core/routes/mutes.ts +95 -95
- package/src/core/routes/roles.ts +135 -135
- package/src/core/runtime.ts +1508 -1508
- package/src/core/task-scheduler.ts +139 -139
- package/src/extensions/catalog.ts +117 -117
- package/src/extensions/hooks.ts +161 -161
- package/src/extensions/installer.ts +306 -306
- package/src/extensions/loader.ts +271 -271
- package/src/extensions/permission-guard.ts +52 -52
- package/src/server.ts +391 -391
- package/src/storage/db.ts +1684 -1687
- package/src/storage/pi-auth.ts +95 -95
- package/src/tts/azure.ts +52 -52
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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: ["
|
|
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: ["
|
|
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) =>
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
+
}
|