mercury-agent 0.4.7 → 0.4.8

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.
@@ -1,191 +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() || 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
- }
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() || 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
+ }