mercury-agent 0.4.5
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/LICENSE +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- package/src/types.ts +295 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
// ─── Profile Schema ───────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const profileEnvVarSchema = z.object({
|
|
17
|
+
key: z.string().regex(/^[A-Z][A-Z0-9_]*$/),
|
|
18
|
+
description: z.string().optional(),
|
|
19
|
+
required: z.boolean().default(false),
|
|
20
|
+
default: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const profileExtensionSchema = z.object({
|
|
24
|
+
name: z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(
|
|
27
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
28
|
+
"Extension names must be lowercase alphanumeric with hyphens",
|
|
29
|
+
),
|
|
30
|
+
source: z.string(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const profileDefaultsSchema = z.object({
|
|
34
|
+
model_provider: z.string().optional(),
|
|
35
|
+
model: z.string().optional(),
|
|
36
|
+
trigger_patterns: z.string().optional(),
|
|
37
|
+
bot_username: z.string().optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const profileSchema = z.object({
|
|
41
|
+
name: z
|
|
42
|
+
.string()
|
|
43
|
+
.regex(
|
|
44
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
45
|
+
"Profile name must be lowercase alphanumeric with hyphens",
|
|
46
|
+
),
|
|
47
|
+
description: z.string().optional(),
|
|
48
|
+
version: z.string().default("0.1.0"),
|
|
49
|
+
agents_md: z.string().optional(),
|
|
50
|
+
extensions: z.array(profileExtensionSchema).default([]),
|
|
51
|
+
env: z.array(profileEnvVarSchema).default([]),
|
|
52
|
+
defaults: profileDefaultsSchema.optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export type MercuryProfile = z.infer<typeof profileSchema>;
|
|
56
|
+
export type ProfileEnvVar = z.infer<typeof profileEnvVarSchema>;
|
|
57
|
+
|
|
58
|
+
// ─── YAML Parsing (lightweight, no dependency) ────────────────────────────
|
|
59
|
+
|
|
60
|
+
function parseSimpleYaml(content: string): Record<string, unknown> {
|
|
61
|
+
const result: Record<string, unknown> = {};
|
|
62
|
+
const lines = content.split("\n");
|
|
63
|
+
let currentKey = "";
|
|
64
|
+
let currentArray: unknown[] | null = null;
|
|
65
|
+
let currentObject: Record<string, unknown> | null = null;
|
|
66
|
+
|
|
67
|
+
for (const raw of lines) {
|
|
68
|
+
const line = raw.replace(/\r$/, "");
|
|
69
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
70
|
+
|
|
71
|
+
// Top-level key: value
|
|
72
|
+
const topMatch = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
73
|
+
if (topMatch) {
|
|
74
|
+
if (currentArray && currentKey) {
|
|
75
|
+
result[currentKey] = currentArray;
|
|
76
|
+
currentArray = null;
|
|
77
|
+
}
|
|
78
|
+
const [, key, value] = topMatch;
|
|
79
|
+
currentKey = key;
|
|
80
|
+
if (value) {
|
|
81
|
+
result[key] = value.replace(/^["']|["']$/g, "");
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Array item: " - key: value" or " - value"
|
|
87
|
+
const arrayItemMatch = line.match(/^\s+-\s+(.+)$/);
|
|
88
|
+
if (arrayItemMatch) {
|
|
89
|
+
if (!currentArray) currentArray = [];
|
|
90
|
+
const item = arrayItemMatch[1];
|
|
91
|
+
|
|
92
|
+
// Check if it's a "key: value" object entry
|
|
93
|
+
const kvMatch = item.match(/^([a-z_]+):\s*(.*)$/);
|
|
94
|
+
if (kvMatch) {
|
|
95
|
+
currentObject = { [kvMatch[1]]: parseYamlValue(kvMatch[2]) };
|
|
96
|
+
currentArray.push(currentObject);
|
|
97
|
+
} else {
|
|
98
|
+
currentObject = null;
|
|
99
|
+
currentArray.push(parseYamlValue(item));
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Nested key inside array object: " key: value"
|
|
105
|
+
const nestedMatch = line.match(/^\s{4,}([a-z_]+):\s*(.*)$/);
|
|
106
|
+
if (nestedMatch && currentObject) {
|
|
107
|
+
currentObject[nestedMatch[1]] = parseYamlValue(nestedMatch[2]);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Nested key for non-array objects (e.g., defaults section)
|
|
112
|
+
const subMatch = line.match(/^\s{2}([a-z_]+):\s*(.*)$/);
|
|
113
|
+
if (subMatch && currentKey && !currentArray) {
|
|
114
|
+
if (
|
|
115
|
+
typeof result[currentKey] !== "object" ||
|
|
116
|
+
result[currentKey] === null
|
|
117
|
+
) {
|
|
118
|
+
result[currentKey] = {};
|
|
119
|
+
}
|
|
120
|
+
(result[currentKey] as Record<string, unknown>)[subMatch[1]] =
|
|
121
|
+
parseYamlValue(subMatch[2]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (currentArray && currentKey) {
|
|
126
|
+
result[currentKey] = currentArray;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseYamlValue(raw: string): string | number | boolean {
|
|
133
|
+
const trimmed = raw.replace(/^["']|["']$/g, "").trim();
|
|
134
|
+
if (trimmed === "true") return true;
|
|
135
|
+
if (trimmed === "false") return false;
|
|
136
|
+
const num = Number(trimmed);
|
|
137
|
+
if (!Number.isNaN(num) && trimmed !== "") return num;
|
|
138
|
+
return trimmed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Profile Loading ──────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export function loadProfileFromDir(dir: string): MercuryProfile {
|
|
144
|
+
const yamlPath = join(dir, "mercury-profile.yaml");
|
|
145
|
+
if (!existsSync(yamlPath)) {
|
|
146
|
+
throw new Error(`Profile manifest not found: ${yamlPath}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const content = readFileSync(yamlPath, "utf-8");
|
|
150
|
+
const parsed = parseSimpleYaml(content);
|
|
151
|
+
return profileSchema.parse(parsed);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function resolveProfileSource(
|
|
155
|
+
source: string,
|
|
156
|
+
profilesDir: string,
|
|
157
|
+
): {
|
|
158
|
+
dir: string;
|
|
159
|
+
cleanup: () => void;
|
|
160
|
+
} {
|
|
161
|
+
// 1. Built-in profile
|
|
162
|
+
const builtinPath = join(profilesDir, source);
|
|
163
|
+
if (existsSync(join(builtinPath, "mercury-profile.yaml"))) {
|
|
164
|
+
return { dir: builtinPath, cleanup: () => {} };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. Local path
|
|
168
|
+
const localPath = resolve(source);
|
|
169
|
+
if (existsSync(join(localPath, "mercury-profile.yaml"))) {
|
|
170
|
+
return { dir: localPath, cleanup: () => {} };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 3. Git URL
|
|
174
|
+
if (
|
|
175
|
+
source.startsWith("http://") ||
|
|
176
|
+
source.startsWith("https://") ||
|
|
177
|
+
source.startsWith("git@")
|
|
178
|
+
) {
|
|
179
|
+
const tmp = join(tmpdir(), `mercury-profile-${Date.now()}`);
|
|
180
|
+
const cloneResult = spawnSync(
|
|
181
|
+
"git",
|
|
182
|
+
["clone", "--depth", "1", source, tmp],
|
|
183
|
+
{
|
|
184
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
if (cloneResult.status !== 0) {
|
|
188
|
+
throw new Error(`Failed to clone profile from ${source}`);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
dir: tmp,
|
|
192
|
+
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw new Error(`Profile not found: ${source}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function applyProfile(
|
|
200
|
+
profile: MercuryProfile,
|
|
201
|
+
profileDir: string,
|
|
202
|
+
projectDir: string,
|
|
203
|
+
): void {
|
|
204
|
+
const dataDir = join(projectDir, ".mercury");
|
|
205
|
+
mkdirSync(dataDir, { recursive: true });
|
|
206
|
+
|
|
207
|
+
// Copy AGENTS.md
|
|
208
|
+
if (profile.agents_md) {
|
|
209
|
+
const agentsMdSrc = join(profileDir, profile.agents_md);
|
|
210
|
+
if (existsSync(agentsMdSrc)) {
|
|
211
|
+
const globalDir = join(dataDir, "global");
|
|
212
|
+
mkdirSync(globalDir, { recursive: true });
|
|
213
|
+
cpSync(agentsMdSrc, join(globalDir, "AGENTS.md"));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Copy extensions
|
|
218
|
+
for (const ext of profile.extensions) {
|
|
219
|
+
if (ext.source.startsWith("./") || ext.source.startsWith("../")) {
|
|
220
|
+
const extSrc = join(profileDir, ext.source);
|
|
221
|
+
if (existsSync(extSrc)) {
|
|
222
|
+
const extDst = join(dataDir, "extensions", ext.name);
|
|
223
|
+
mkdirSync(dirname(extDst), { recursive: true });
|
|
224
|
+
cpSync(extSrc, extDst, { recursive: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function listBuiltinProfiles(profilesDir: string): MercuryProfile[] {
|
|
231
|
+
if (!existsSync(profilesDir)) return [];
|
|
232
|
+
|
|
233
|
+
const profiles: MercuryProfile[] = [];
|
|
234
|
+
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
|
235
|
+
if (!entry.isDirectory()) continue;
|
|
236
|
+
const yamlPath = join(profilesDir, entry.name, "mercury-profile.yaml");
|
|
237
|
+
if (!existsSync(yamlPath)) continue;
|
|
238
|
+
try {
|
|
239
|
+
profiles.push(loadProfileFromDir(join(profilesDir, entry.name)));
|
|
240
|
+
} catch {
|
|
241
|
+
// Skip invalid profiles
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return profiles;
|
|
245
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding window rate limiter for per-user per-group message limiting.
|
|
3
|
+
*
|
|
4
|
+
* Uses a simple sliding window approach: tracks timestamps of recent requests
|
|
5
|
+
* and counts how many fall within the current window.
|
|
6
|
+
*/
|
|
7
|
+
export class RateLimiter {
|
|
8
|
+
/** Map of "spaceId:userId" -> array of request timestamps */
|
|
9
|
+
private readonly buckets = new Map<string, number[]>();
|
|
10
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
/** Max requests per user per group within the window */
|
|
14
|
+
private readonly maxRequests: number,
|
|
15
|
+
/** Window size in milliseconds */
|
|
16
|
+
private readonly windowMs: number,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if a request is allowed and record it if so.
|
|
21
|
+
* @param limitOverride - Optional per-group limit override
|
|
22
|
+
* @returns true if allowed, false if rate limited
|
|
23
|
+
*/
|
|
24
|
+
isAllowed(spaceId: string, userId: string, limitOverride?: number): boolean {
|
|
25
|
+
const key = `${spaceId}:${userId}`;
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const windowStart = now - this.windowMs;
|
|
28
|
+
const effectiveLimit = limitOverride ?? this.maxRequests;
|
|
29
|
+
|
|
30
|
+
let timestamps = this.buckets.get(key);
|
|
31
|
+
if (!timestamps) {
|
|
32
|
+
timestamps = [];
|
|
33
|
+
this.buckets.set(key, timestamps);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Remove timestamps outside the current window
|
|
37
|
+
const validTimestamps = timestamps.filter((t) => t > windowStart);
|
|
38
|
+
|
|
39
|
+
if (validTimestamps.length >= effectiveLimit) {
|
|
40
|
+
// Over limit — update bucket with pruned timestamps but don't add new one
|
|
41
|
+
this.buckets.set(key, validTimestamps);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Under limit — record this request
|
|
46
|
+
validTimestamps.push(now);
|
|
47
|
+
this.buckets.set(key, validTimestamps);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get remaining requests for a user in a group.
|
|
53
|
+
*/
|
|
54
|
+
getRemaining(spaceId: string, userId: string): number {
|
|
55
|
+
const key = `${spaceId}:${userId}`;
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const windowStart = now - this.windowMs;
|
|
58
|
+
|
|
59
|
+
const timestamps = this.buckets.get(key);
|
|
60
|
+
if (!timestamps) return this.maxRequests;
|
|
61
|
+
|
|
62
|
+
const validCount = timestamps.filter((t) => t > windowStart).length;
|
|
63
|
+
return Math.max(0, this.maxRequests - validCount);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Start periodic cleanup of expired entries.
|
|
68
|
+
* Call this once at startup to prevent memory leaks.
|
|
69
|
+
*/
|
|
70
|
+
startCleanup(intervalMs = 60_000): void {
|
|
71
|
+
if (this.cleanupTimer) return;
|
|
72
|
+
|
|
73
|
+
this.cleanupTimer = setInterval(() => {
|
|
74
|
+
this.cleanup();
|
|
75
|
+
}, intervalMs);
|
|
76
|
+
|
|
77
|
+
// Don't keep the process alive just for cleanup
|
|
78
|
+
if (this.cleanupTimer.unref) {
|
|
79
|
+
this.cleanupTimer.unref();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stop the cleanup timer.
|
|
85
|
+
*/
|
|
86
|
+
stopCleanup(): void {
|
|
87
|
+
if (this.cleanupTimer) {
|
|
88
|
+
clearInterval(this.cleanupTimer);
|
|
89
|
+
this.cleanupTimer = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Remove all expired entries from the bucket map.
|
|
95
|
+
*/
|
|
96
|
+
cleanup(): number {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const windowStart = now - this.windowMs;
|
|
99
|
+
let removed = 0;
|
|
100
|
+
|
|
101
|
+
for (const [key, timestamps] of this.buckets) {
|
|
102
|
+
const valid = timestamps.filter((t) => t > windowStart);
|
|
103
|
+
if (valid.length === 0) {
|
|
104
|
+
this.buckets.delete(key);
|
|
105
|
+
removed++;
|
|
106
|
+
} else if (valid.length !== timestamps.length) {
|
|
107
|
+
this.buckets.set(key, valid);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return removed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Clear all rate limit state. Useful for testing.
|
|
116
|
+
*/
|
|
117
|
+
clear(): void {
|
|
118
|
+
this.buckets.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get the number of tracked buckets (for monitoring).
|
|
123
|
+
*/
|
|
124
|
+
get bucketCount(): number {
|
|
125
|
+
return this.buckets.size;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { AppConfig } from "../config.js";
|
|
2
|
+
import type { Db } from "../storage/db.js";
|
|
3
|
+
import type { MessageAttachment } from "../types.js";
|
|
4
|
+
import { SLASH_COMMANDS } from "./commands.js";
|
|
5
|
+
import { hasPermission, resolveRole } from "./permissions.js";
|
|
6
|
+
import { loadTriggerConfig, matchTrigger } from "./trigger.js";
|
|
7
|
+
|
|
8
|
+
export type RouteResult =
|
|
9
|
+
| {
|
|
10
|
+
type: "assistant";
|
|
11
|
+
prompt: string;
|
|
12
|
+
callerId: string;
|
|
13
|
+
role: string;
|
|
14
|
+
isReplyToBot: boolean;
|
|
15
|
+
isDM: boolean;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
type: "command";
|
|
19
|
+
command: string;
|
|
20
|
+
verb?: string;
|
|
21
|
+
arg?: string;
|
|
22
|
+
callerId: string;
|
|
23
|
+
role: string;
|
|
24
|
+
}
|
|
25
|
+
| { type: "denied"; reason: string }
|
|
26
|
+
| { type: "ignore" };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Chat-level commands that bypass the LLM.
|
|
30
|
+
* Mapped to the permission required to execute them.
|
|
31
|
+
*/
|
|
32
|
+
const CHAT_COMMANDS: Record<string, string> = {
|
|
33
|
+
stop: "stop",
|
|
34
|
+
compact: "compact",
|
|
35
|
+
clear: "clear",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function routeInput(input: {
|
|
39
|
+
text: string;
|
|
40
|
+
spaceId: string;
|
|
41
|
+
callerId: string;
|
|
42
|
+
isDM: boolean;
|
|
43
|
+
isReplyToBot: boolean;
|
|
44
|
+
db: Db;
|
|
45
|
+
config: AppConfig;
|
|
46
|
+
/** Attachments after normalize (saved to inbox). */
|
|
47
|
+
attachments?: MessageAttachment[];
|
|
48
|
+
/**
|
|
49
|
+
* True when the inbound message had attachments or downloadable media before
|
|
50
|
+
* normalize, even if nothing was persisted (routing vs silent drop).
|
|
51
|
+
*/
|
|
52
|
+
hadIncomingAttachments?: boolean;
|
|
53
|
+
/** Display name of the message author (e.g. WhatsApp pushName). */
|
|
54
|
+
authorName?: string | null;
|
|
55
|
+
}): RouteResult {
|
|
56
|
+
const text = input.text.trim();
|
|
57
|
+
const persisted = (input.attachments?.length ?? 0) > 0;
|
|
58
|
+
const hadIncoming = input.hadIncomingAttachments ?? false;
|
|
59
|
+
const hasAttachments = persisted || hadIncoming;
|
|
60
|
+
if (!text && !hasAttachments) return { type: "ignore" };
|
|
61
|
+
|
|
62
|
+
const seededAdmins = input.config.admins
|
|
63
|
+
? input.config.admins
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((s) => s.trim())
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
: [];
|
|
68
|
+
|
|
69
|
+
input.db.ensureSpace(input.spaceId);
|
|
70
|
+
|
|
71
|
+
// Resolve role (seeds admins + auto-upserts member)
|
|
72
|
+
const role = resolveRole(
|
|
73
|
+
input.db,
|
|
74
|
+
input.spaceId,
|
|
75
|
+
input.callerId,
|
|
76
|
+
seededAdmins,
|
|
77
|
+
input.authorName,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Load trigger config for this group
|
|
81
|
+
const defaultPatterns = input.config.triggerPatterns
|
|
82
|
+
.split(",")
|
|
83
|
+
.map((s) => s.trim())
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
const triggerConfig = loadTriggerConfig(input.db, input.spaceId, {
|
|
86
|
+
patterns: defaultPatterns,
|
|
87
|
+
match: input.config.triggerMatch,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Match trigger OR reply-to-bot
|
|
91
|
+
const result = matchTrigger(text, triggerConfig, input.isDM, hasAttachments);
|
|
92
|
+
const isReplyTrigger = input.isReplyToBot && !input.isDM;
|
|
93
|
+
if (!result.matched && !isReplyTrigger) return { type: "ignore" };
|
|
94
|
+
|
|
95
|
+
// Use stripped prompt if trigger matched, otherwise full text for replies
|
|
96
|
+
const prompt = result.matched ? result.prompt : text;
|
|
97
|
+
|
|
98
|
+
// Check for slash commands (e.g. "/model list", "/model switch 2")
|
|
99
|
+
// Only parse the first line — appended context (reply quotes etc.) must not
|
|
100
|
+
// bleed into verb/arg tokens.
|
|
101
|
+
if (prompt.startsWith("/")) {
|
|
102
|
+
const firstLine = prompt.split("\n")[0].trim();
|
|
103
|
+
const [rawCategory, rawVerb, ...argParts] = firstLine
|
|
104
|
+
.slice(1)
|
|
105
|
+
.trim()
|
|
106
|
+
.split(/\s+/);
|
|
107
|
+
const category = rawCategory.toLowerCase();
|
|
108
|
+
const verb = rawVerb?.toLowerCase() || undefined;
|
|
109
|
+
const arg = argParts.join(" ").trim().toLowerCase() || undefined;
|
|
110
|
+
if (SLASH_COMMANDS.some((c) => c.name === category)) {
|
|
111
|
+
return gateSlashCommand(
|
|
112
|
+
input.db,
|
|
113
|
+
input.spaceId,
|
|
114
|
+
category,
|
|
115
|
+
role,
|
|
116
|
+
input.callerId,
|
|
117
|
+
input.isDM,
|
|
118
|
+
verb,
|
|
119
|
+
arg,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check for commands after trigger (e.g. "@Pi stop", "Pi compact")
|
|
125
|
+
const cmdWord = prompt.toLowerCase().trim();
|
|
126
|
+
if (cmdWord in CHAT_COMMANDS) {
|
|
127
|
+
return gateCommand(input.db, input.spaceId, cmdWord, role, input.callerId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check prompt permission
|
|
131
|
+
if (!hasPermission(input.db, input.spaceId, role, "prompt")) {
|
|
132
|
+
return {
|
|
133
|
+
type: "denied",
|
|
134
|
+
reason: "You don't have permission to use the agent in this group.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
type: "assistant",
|
|
140
|
+
prompt,
|
|
141
|
+
callerId: input.callerId,
|
|
142
|
+
role,
|
|
143
|
+
isReplyToBot: input.isReplyToBot,
|
|
144
|
+
isDM: input.isDM,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function gateSlashCommand(
|
|
149
|
+
db: Db,
|
|
150
|
+
spaceId: string,
|
|
151
|
+
command: string,
|
|
152
|
+
role: string,
|
|
153
|
+
callerId: string,
|
|
154
|
+
isDM: boolean,
|
|
155
|
+
verb?: string,
|
|
156
|
+
arg?: string,
|
|
157
|
+
): RouteResult {
|
|
158
|
+
if (!isDM && role !== "admin" && role !== "system") {
|
|
159
|
+
return {
|
|
160
|
+
type: "denied",
|
|
161
|
+
reason: "Slash commands are only available to admins in groups.",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!hasPermission(db, spaceId, role, "prompt")) {
|
|
165
|
+
return {
|
|
166
|
+
type: "denied",
|
|
167
|
+
reason: `You don't have permission to use '/${command}'.`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { type: "command", command, verb, arg, callerId, role };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function gateCommand(
|
|
174
|
+
db: Db,
|
|
175
|
+
spaceId: string,
|
|
176
|
+
command: string,
|
|
177
|
+
role: string,
|
|
178
|
+
callerId: string,
|
|
179
|
+
): RouteResult {
|
|
180
|
+
const permission = CHAT_COMMANDS[command];
|
|
181
|
+
if (!permission) return { type: "ignore" };
|
|
182
|
+
|
|
183
|
+
if (!hasPermission(db, spaceId, role, permission)) {
|
|
184
|
+
return {
|
|
185
|
+
type: "denied",
|
|
186
|
+
reason: `You don't have permission to use '${command}'.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { type: "command", command, callerId, role };
|
|
191
|
+
}
|