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.
Files changed (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. 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
+ }