mercury-agent 0.4.16 → 0.4.18

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/README.md CHANGED
@@ -293,21 +293,6 @@ export default function(mercury) {
293
293
 
294
294
  Extensions with CLIs get auto-installed into a derived Docker image. Skills are symlinked for agent discovery. Permissions integrate with the existing RBAC system.
295
295
 
296
- ### Official Extensions
297
-
298
- Install curated extensions from the [`@mercuryai`](https://github.com/Michaelliv/mercury-extensions) scope:
299
-
300
- ```bash
301
- mercury add @mercuryai/knowledge # Obsidian-based knowledge vault with KB distillation
302
- mercury add @mercuryai/web-browser # Web browsing via Playwright/Chromium
303
- mercury add @mercuryai/charts # Chart generation
304
- mercury add @mercuryai/github # GitHub CLI integration
305
- mercury add @mercuryai/google-workspace # Google Workspace (Gmail, Calendar, Drive)
306
- mercury add @mercuryai/pdf-tools # PDF processing, OCR, and form filling
307
- ```
308
-
309
- See [mercury-extensions](https://github.com/Michaelliv/mercury-extensions) for the full list and documentation.
310
-
311
296
  See [docs/extensions.md](docs/extensions.md) for the extension system guide.
312
297
 
313
298
  ---
@@ -370,7 +355,7 @@ Supported OAuth providers: Anthropic, GitHub Copilot, Google Gemini CLI, Antigra
370
355
 
371
356
  | Variable | Default | Description |
372
357
  |----------|---------|-------------|
373
- | `MERCURY_AGENT_CONTAINER_IMAGE` | `mercury-agent:latest` | Container image |
358
+ | `MERCURY_AGENT_CONTAINER_IMAGE` | `ghcr.io/avishai-tsabari/mercury-agent:latest` | Container image |
374
359
  | `MERCURY_CONTAINER_TIMEOUT_MS` | `300000` | Container timeout (5 min) |
375
360
 
376
361
  **KB Distillation:**
@@ -11,15 +11,19 @@ Message received
11
11
 
12
12
  ├─► Type = "assistant"?
13
13
  │ │
14
- │ ├─► Check rate limit
15
- │ │ • Key: spaceId:userId
16
- │ │ • Count requests in sliding window
17
- │ │ • Compare against effective limit
14
+ │ ├─► Daily role check (if rate_limit.<role> key set and > 0)
15
+ │ │ • SQLite counter (space_id + user_id + UTC date)
16
+ │ │ • Denied: "You've used N/M messages today. Resets in Xh."
18
17
  │ │
19
- │ ├─► Under limit record request → continue
20
- └─► Over limit → return "Rate limit exceeded"
18
+ │ ├─► Burst check (sliding window, always runs)
19
+ │ • In-memory, key: spaceId:userId
20
+ │ │ • Count requests in 60s window
21
+ │ │ • Denied: "Rate limit exceeded. Try again shortly."
22
+ │ │
23
+ │ ├─► Both pass → continue to container
24
+ │ └─► Either fails → return denial
21
25
 
22
- └─► Type = "command" / "ignore" → bypass rate limit
26
+ └─► Type = "command" / "ignore" → bypass rate limits
23
27
  ```
24
28
 
25
29
  Commands like `stop` and `compact` bypass rate limiting so users can always abort runaway containers.
@@ -160,6 +164,65 @@ The system prompt instructs the agent to:
160
164
 
161
165
  Mutes are per-space and expire automatically after the specified duration.
162
166
 
167
+ ## Role-Based Daily Limits
168
+
169
+ In addition to the burst limiter, spaces can set per-role daily message caps. This gives agent owners cost control — admins get unlimited access to their own bot while capping how much others can use it.
170
+
171
+ ### Configuration
172
+
173
+ Set `rate_limit.<role>` keys via `mrctl` or the API:
174
+
175
+ ```bash
176
+ # Admins: unlimited (0 = no daily limit)
177
+ mrctl config set rate_limit.admin 0
178
+
179
+ # Members: 5 messages per day
180
+ mrctl config set rate_limit.member 5
181
+ ```
182
+
183
+ Via API:
184
+ ```bash
185
+ curl -X PUT http://localhost:8787/api/config \
186
+ -H "X-Mercury-Space: slack:C123" \
187
+ -H "X-Mercury-Caller: slack:U456" \
188
+ -H "Content-Type: application/json" \
189
+ -d '{"key": "rate_limit.member", "value": "5"}'
190
+ ```
191
+
192
+ ### Behavior
193
+
194
+ | Value | Effect |
195
+ |-------|--------|
196
+ | `0` | Unlimited (daily check skipped for this role) |
197
+ | `> 0` | Daily cap enforced; denied requests show count + reset time |
198
+ | Not set | No daily limit for this role (burst limiter still applies) |
199
+ | Invalid (NaN) | Treated as "not set" |
200
+
201
+ ### How the two layers interact
202
+
203
+ ```
204
+ Message arrives (type = "assistant")
205
+
206
+ ├─► Daily role check (if rate_limit.<role> key exists and > 0)
207
+ │ • Key: space_id + user_id + UTC date
208
+ │ • SQLite counter (persists across restarts)
209
+ │ • Denied: "You've used 5/5 messages today. Resets in 3h."
210
+
211
+ └─► Burst check (sliding window, always runs for all users)
212
+ • In-memory timestamps
213
+ • Denied: "Rate limit exceeded. Try again shortly."
214
+ ```
215
+
216
+ Both layers are independent — both must pass. A user can be allowed by the daily check but blocked by the burst limiter, or vice versa. Denied requests do not increment the daily counter.
217
+
218
+ ### Special cases
219
+
220
+ - **System callers** (scheduled tasks): exempt from daily limits unconditionally
221
+ - **Commands** (`stop`, `compact`, etc.): bypass both layers
222
+ - **Custom roles**: set `rate_limit.<custom_role>` for any role name
223
+ - **Daily reset**: counters reset at UTC midnight
224
+ - **Counters are per-space**: each space tracks independently
225
+
163
226
  ## See Also
164
227
 
165
228
  - [pipeline.md](./pipeline.md) — Message flow and routing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -4,7 +4,6 @@ import { spawn, spawnSync } from "node:child_process";
4
4
  import {
5
5
  chmodSync,
6
6
  copyFileSync,
7
- cpSync,
8
7
  existsSync,
9
8
  mkdirSync,
10
9
  readdirSync,
@@ -814,7 +813,7 @@ program
814
813
  if (existsSync(profileExtDir)) {
815
814
  const userExtDir = join(CWD, ".mercury/extensions");
816
815
  mkdirSync(userExtDir, { recursive: true });
817
- cpSync(profileExtDir, userExtDir, { recursive: true });
816
+ copyDirRecursive(profileExtDir, userExtDir);
818
817
  }
819
818
  }
820
819
  }
@@ -2112,7 +2111,7 @@ profilesCommand
2112
2111
  // Copy extensions
2113
2112
  const userExtDir = join(CWD, ".mercury/extensions");
2114
2113
  if (existsSync(userExtDir)) {
2115
- cpSync(userExtDir, join(absOutput, "extensions"), { recursive: true });
2114
+ copyDirRecursive(userExtDir, join(absOutput, "extensions"));
2116
2115
  }
2117
2116
 
2118
2117
  // Generate manifest
@@ -1,76 +1,110 @@
1
- export interface CommandVerb {
2
- verb: string;
3
- args?: string; // e.g. "<N|MODEL_ID>"
4
- description: string;
5
- }
6
-
7
- export interface CommandEntry {
8
- name: string; // e.g. "model", "help"
9
- description: string; // one-liner shown in /help
10
- verbs?: CommandVerb[]; // defined ⟹ category; absent ⟹ leaf command
11
- }
12
-
13
- // Slash-command registry — source of truth for /help and router recognition.
14
- // Add new categories here; router + executeCommand both derive from this list.
15
- export const SLASH_COMMANDS: CommandEntry[] = [
16
- {
17
- name: "help",
18
- description: "list all available commands",
19
- },
20
- {
21
- name: "model",
22
- description: "list, switch, and inspect your AI models",
23
- verbs: [
24
- { verb: "list", description: "list your configured models" },
25
- { verb: "active", description: "show the active model" },
26
- {
27
- verb: "switch",
28
- args: "<N|MODEL_ID>",
29
- description: "switch by number or model ID",
30
- },
31
- ],
32
- },
33
- ];
34
-
35
- // Legacy bare commands still work without a slash; shown in /help for discoverability.
36
- export const BARE_COMMANDS: Array<{ name: string; description: string }> = [
37
- {
38
- name: "stop",
39
- description: "abort the current agent run and queued requests",
40
- },
41
- { name: "compact", description: "reset the session context window" },
42
- { name: "clear", description: "clear context for this message" },
43
- ];
44
-
45
- // Generates the /help response listing all commands.
46
- export function formatHelp(): string {
47
- const lines: string[] = ["Available commands:", ""];
48
-
49
- for (const cmd of SLASH_COMMANDS) {
50
- lines.push(` /${cmd.name.padEnd(12)} ${cmd.description}`);
51
- }
52
-
53
- lines.push("", "Bare commands (no slash needed):");
54
- for (const cmd of BARE_COMMANDS) {
55
- lines.push(` ${cmd.name.padEnd(12)} ${cmd.description}`);
56
- }
57
-
58
- lines.push("", "Type /<category> to see detailed help for that category.");
59
- return lines.join("\n");
60
- }
61
-
62
- // Generates the /<category> no-verb response.
63
- // Returns null if name is not in the registry or the command has no verbs.
64
- export function formatCategoryHelp(name: string): string | null {
65
- const entry = SLASH_COMMANDS.find((c) => c.name === name);
66
- if (!entry?.verbs || entry.verbs.length === 0) return null;
67
-
68
- const lines: string[] = [`/${name} — ${entry.description}`, ""];
69
- for (const v of entry.verbs) {
70
- const usage = v.args
71
- ? `/${name} ${v.verb} ${v.args}`
72
- : `/${name} ${v.verb}`;
73
- lines.push(` ${usage.padEnd(28)} ${v.description}`);
74
- }
75
- return lines.join("\n");
76
- }
1
+ export interface CommandVerb {
2
+ verb: string;
3
+ args?: string; // e.g. "<N|MODEL_ID>"
4
+ description: string;
5
+ }
6
+
7
+ export interface CommandEntry {
8
+ name: string; // e.g. "model", "help"
9
+ description: string; // one-liner shown in /help
10
+ verbs?: CommandVerb[]; // defined ⟹ category; absent ⟹ leaf command
11
+ }
12
+
13
+ // Slash-command registry — source of truth for /help and router recognition.
14
+ // Add new categories here; router + executeCommand both derive from this list.
15
+ export const SLASH_COMMANDS: CommandEntry[] = [
16
+ {
17
+ name: "help",
18
+ description: "list all available commands",
19
+ },
20
+ {
21
+ name: "model",
22
+ description: "list, switch, and inspect your AI models",
23
+ verbs: [
24
+ { verb: "list", description: "list your configured models" },
25
+ { verb: "active", description: "show the active model" },
26
+ {
27
+ verb: "switch",
28
+ args: "<N|MODEL_ID>",
29
+ description: "switch by number or model ID",
30
+ },
31
+ ],
32
+ },
33
+ {
34
+ name: "spaces",
35
+ description: "create, list, switch, and delete spaces",
36
+ verbs: [
37
+ { verb: "list", description: "list all spaces with conversation counts" },
38
+ {
39
+ verb: "create",
40
+ args: "<id> <name>",
41
+ description: "create a new space",
42
+ },
43
+ {
44
+ verb: "switch",
45
+ args: "<id>",
46
+ description: "switch this conversation to a different space",
47
+ },
48
+ {
49
+ verb: "delete",
50
+ args: "<id>",
51
+ description: "delete a space (requires confirmation)",
52
+ },
53
+ {
54
+ verb: "unlink",
55
+ description: "unlink this conversation from its space",
56
+ },
57
+ ],
58
+ },
59
+ {
60
+ name: "pause",
61
+ description: "pause the bot in this space (optional: /pause <duration>)",
62
+ },
63
+ {
64
+ name: "resume",
65
+ description: "resume the bot in this space",
66
+ },
67
+ ];
68
+
69
+ // Legacy bare commands — still work without a slash; shown in /help for discoverability.
70
+ export const BARE_COMMANDS: Array<{ name: string; description: string }> = [
71
+ {
72
+ name: "stop",
73
+ description: "abort the current agent run and queued requests",
74
+ },
75
+ { name: "compact", description: "reset the session context window" },
76
+ { name: "clear", description: "clear context for this message" },
77
+ ];
78
+
79
+ // Generates the /help response listing all commands.
80
+ export function formatHelp(): string {
81
+ const lines: string[] = ["Available commands:", ""];
82
+
83
+ for (const cmd of SLASH_COMMANDS) {
84
+ lines.push(` /${cmd.name.padEnd(12)} ${cmd.description}`);
85
+ }
86
+
87
+ lines.push("", "Bare commands (no slash needed):");
88
+ for (const cmd of BARE_COMMANDS) {
89
+ lines.push(` ${cmd.name.padEnd(12)} ${cmd.description}`);
90
+ }
91
+
92
+ lines.push("", "Type /<category> to see detailed help for that category.");
93
+ return lines.join("\n");
94
+ }
95
+
96
+ // Generates the /<category> no-verb response.
97
+ // Returns null if name is not in the registry or the command has no verbs.
98
+ export function formatCategoryHelp(name: string): string | null {
99
+ const entry = SLASH_COMMANDS.find((c) => c.name === name);
100
+ if (!entry?.verbs || entry.verbs.length === 0) return null;
101
+
102
+ const lines: string[] = [`/${name} — ${entry.description}`, ""];
103
+ for (const v of entry.verbs) {
104
+ const usage = v.args
105
+ ? `/${name} ${v.verb} ${v.args}`
106
+ : `/${name} ${v.verb}`;
107
+ lines.push(` ${usage.padEnd(28)} ${v.description}`);
108
+ }
109
+ return lines.join("\n");
110
+ }
@@ -25,6 +25,8 @@ export type RouteResult =
25
25
  | { type: "denied"; reason: string }
26
26
  | { type: "ignore" };
27
27
 
28
+ const SEEDED_ADMIN_COMMANDS = new Set(["spaces"]);
29
+
28
30
  /**
29
31
  * Chat-level commands that bypass the LLM.
30
32
  * Mapped to the permission required to execute them.
@@ -115,6 +117,7 @@ export function routeInput(input: {
115
117
  role,
116
118
  input.callerId,
117
119
  input.isDM,
120
+ seededAdmins,
118
121
  verb,
119
122
  arg,
120
123
  );
@@ -152,9 +155,19 @@ function gateSlashCommand(
152
155
  role: string,
153
156
  callerId: string,
154
157
  isDM: boolean,
158
+ seededAdmins: string[],
155
159
  verb?: string,
156
160
  arg?: string,
157
161
  ): RouteResult {
162
+ if (SEEDED_ADMIN_COMMANDS.has(command)) {
163
+ if (!seededAdmins.includes(callerId)) {
164
+ return {
165
+ type: "denied",
166
+ reason: `You don't have permission to use '/${command}'.`,
167
+ };
168
+ }
169
+ return { type: "command", command, verb, arg, callerId, role };
170
+ }
158
171
  if (!isDM && role !== "admin" && role !== "system") {
159
172
  return {
160
173
  type: "denied",
@@ -1123,10 +1123,12 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1123
1123
  </form>
1124
1124
  `;
1125
1125
 
1126
+ const isPaused = core.db.getSpaceConfig(spaceId, "paused") === "true";
1127
+
1126
1128
  return c.html(html`
1127
1129
  <div class="page-header">
1128
1130
  <a href="#" hx-get="/dashboard/page/spaces" hx-target="#main" hx-push-url="true" class="back">← Back</a>
1129
- <h2>${escapeHtml(group.name)}</h2>
1131
+ <h2>${escapeHtml(group.name)}${isPaused ? raw(' <span class="badge" style="background:var(--color-warning);color:var(--bg);font-size:11px;vertical-align:middle">⏸ Paused</span>') : ""}</h2>
1130
1132
  </div>
1131
1133
 
1132
1134
  <div class="grid-2">
@@ -69,6 +69,8 @@ export class MercuryCoreRuntime {
69
69
  private extensionCtx: MercuryExtensionContext | null = null;
70
70
  private extensionRegistry: ExtensionRegistry | null = null;
71
71
  private readonly shutdownHooks: ShutdownHook[] = [];
72
+ private readonly pauseTimers = new Map<string, NodeJS.Timeout>();
73
+ private messageSender: MessageSender | undefined;
72
74
  private shuttingDown = false;
73
75
  private signalHandlersInstalled = false;
74
76
 
@@ -272,6 +274,8 @@ export class MercuryCoreRuntime {
272
274
  }
273
275
 
274
276
  startScheduler(sender?: MessageSender): void {
277
+ this.messageSender = sender;
278
+ this.restorePauseTimers();
275
279
  this.scheduler.start(async (task) => {
276
280
  const result = await this.executePrompt(
277
281
  task.spaceId,
@@ -316,6 +320,8 @@ export class MercuryCoreRuntime {
316
320
 
317
321
  stopScheduler(): void {
318
322
  this.scheduler.stop();
323
+ for (const t of this.pauseTimers.values()) clearTimeout(t);
324
+ this.pauseTimers.clear();
319
325
  }
320
326
 
321
327
  async handleRawInput(
@@ -335,6 +341,34 @@ export class MercuryCoreRuntime {
335
341
  authorName: message.authorName,
336
342
  });
337
343
 
344
+ const pendingDeleteResult = this.handlePendingSpaceDelete(
345
+ message.spaceId,
346
+ message.callerId,
347
+ message.text.trim().toLowerCase(),
348
+ );
349
+ if (pendingDeleteResult) {
350
+ return {
351
+ type: "command",
352
+ command: "spaces",
353
+ callerId: message.callerId,
354
+ role:
355
+ route.type === "command" || route.type === "assistant"
356
+ ? route.role
357
+ : "admin",
358
+ result: { reply: pendingDeleteResult, files: [] },
359
+ };
360
+ }
361
+
362
+ // Pause guard — drop everything except /pause and /resume when paused
363
+ if (this.db.getSpaceConfig(message.spaceId, "paused") === "true") {
364
+ const exempt =
365
+ route.type === "command" &&
366
+ (route.command === "pause" || route.command === "resume");
367
+ if (!exempt) {
368
+ return { type: "ignore" };
369
+ }
370
+ }
371
+
338
372
  if (route.type === "command") {
339
373
  const reply = await this.executeCommand(
340
374
  message.spaceId,
@@ -342,6 +376,10 @@ export class MercuryCoreRuntime {
342
376
  route.callerId,
343
377
  route.verb,
344
378
  route.arg,
379
+ {
380
+ platform: message.platform,
381
+ externalId: message.conversationExternalId,
382
+ },
345
383
  );
346
384
  return { ...route, result: { reply, files: [] } };
347
385
  }
@@ -356,7 +394,33 @@ export class MercuryCoreRuntime {
356
394
 
357
395
  // Check rate limit for assistant requests (not commands, not ignored messages)
358
396
  if (route.type === "assistant") {
359
- // Check per-group override first
397
+ // Daily role-based rate check (before burst limiter)
398
+ if (route.role !== "system") {
399
+ const roleKey = `rate_limit.${route.role}`;
400
+ const roleLimitRaw = this.db.getSpaceConfig(message.spaceId, roleKey);
401
+ if (roleLimitRaw !== null) {
402
+ const roleLimit = Number.parseInt(roleLimitRaw, 10);
403
+ if (!Number.isNaN(roleLimit) && roleLimit > 0) {
404
+ const daily = this.db.checkAndIncrementDailyUsage(
405
+ message.spaceId,
406
+ message.callerId,
407
+ roleLimit,
408
+ );
409
+ if (!daily.allowed) {
410
+ const msUntilReset =
411
+ new Date().setUTCHours(24, 0, 0, 0) - Date.now();
412
+ const hoursLeft = Math.ceil(msUntilReset / 3_600_000);
413
+ return {
414
+ type: "denied",
415
+ reason: `You've used ${daily.count}/${roleLimit} messages today. Resets in ${hoursLeft}h.`,
416
+ };
417
+ }
418
+ }
419
+ // roleLimit === 0 means unlimited — skip daily check
420
+ }
421
+ }
422
+
423
+ // Burst rate limit (sliding window)
360
424
  const groupLimit = this.db.getSpaceConfig(message.spaceId, "rate_limit");
361
425
  const effectiveLimit = groupLimit
362
426
  ? Number.parseInt(groupLimit, 10)
@@ -486,6 +550,7 @@ export class MercuryCoreRuntime {
486
550
  callerId: string,
487
551
  verb?: string,
488
552
  arg?: string,
553
+ conversationContext?: { platform: string; externalId: string },
489
554
  ): Promise<string> {
490
555
  switch (command) {
491
556
  case "stop": {
@@ -514,6 +579,18 @@ export class MercuryCoreRuntime {
514
579
  }
515
580
  case "model":
516
581
  return this.executeModelsCommand(spaceId, callerId, verb, arg);
582
+ case "spaces":
583
+ return this.executeSpacesCommand(
584
+ spaceId,
585
+ callerId,
586
+ conversationContext,
587
+ verb,
588
+ arg,
589
+ );
590
+ case "pause":
591
+ return this.executePauseCommand(spaceId, callerId, verb);
592
+ case "resume":
593
+ return this.executeResumeCommand(spaceId, callerId);
517
594
  default:
518
595
  return `Unknown command: ${command}`;
519
596
  }
@@ -593,6 +670,295 @@ export class MercuryCoreRuntime {
593
670
  }
594
671
  }
595
672
 
673
+ private executeSpacesCommand(
674
+ spaceId: string,
675
+ _callerId: string,
676
+ conversationContext?: { platform: string; externalId: string },
677
+ verb?: string,
678
+ arg?: string,
679
+ ): string {
680
+ this.clearPendingDelete(spaceId);
681
+
682
+ if (!verb) {
683
+ return formatCategoryHelp("spaces") ?? "/spaces — space management";
684
+ }
685
+
686
+ switch (verb) {
687
+ case "list": {
688
+ const spaces = this.db.listSpaces();
689
+ if (spaces.length === 0) return "No spaces.";
690
+ const lines = ["Spaces:"];
691
+ for (const s of spaces) {
692
+ const convos = this.db.getSpaceConversations(s.id);
693
+ lines.push(
694
+ ` ${s.id.padEnd(16)} ${s.name} (${convos.length} conversation${convos.length === 1 ? "" : "s"})`,
695
+ );
696
+ }
697
+ return lines.join("\n");
698
+ }
699
+
700
+ case "create": {
701
+ if (!arg) return "Usage: /spaces create <id> <name>";
702
+ const firstSpace = arg.indexOf(" ");
703
+ if (firstSpace === -1) return "Usage: /spaces create <id> <name>";
704
+ const newId = arg.slice(0, firstSpace);
705
+ const newName = arg.slice(firstSpace + 1).trim();
706
+ if (!newName) return "Usage: /spaces create <id> <name>";
707
+ try {
708
+ this.db.createSpace(newId, newName);
709
+ } catch (err) {
710
+ return err instanceof Error ? err.message : String(err);
711
+ }
712
+ return `Created space '${newId}' (${newName}).`;
713
+ }
714
+
715
+ case "switch": {
716
+ if (!arg) return "Usage: /spaces switch <id>";
717
+ const targetSpace = this.db.getSpace(arg);
718
+ if (!targetSpace) return `Space '${arg}' not found.`;
719
+ if (!conversationContext) {
720
+ return "Cannot determine current conversation.";
721
+ }
722
+ const convo = this.db.findConversation(
723
+ conversationContext.platform,
724
+ conversationContext.externalId,
725
+ );
726
+ if (!convo) return "Current conversation not found.";
727
+ if (convo.spaceId === arg) return `Already in space '${arg}'.`;
728
+ this.db.linkConversation(convo.id, arg);
729
+ return `Switched to space '${targetSpace.name}' (${arg}).`;
730
+ }
731
+
732
+ case "delete": {
733
+ if (!arg) return "Usage: /spaces delete <id>";
734
+ if (arg === "main") return "Cannot delete the default 'main' space.";
735
+ const target = this.db.getSpace(arg);
736
+ if (!target) return `Space '${arg}' not found.`;
737
+ this.db.setSpaceConfig(
738
+ spaceId,
739
+ "spaces.pending_delete_id",
740
+ arg,
741
+ "system",
742
+ );
743
+ this.db.setSpaceConfig(
744
+ spaceId,
745
+ "spaces.pending_delete_at",
746
+ new Date().toISOString(),
747
+ "system",
748
+ );
749
+ const isSelf = spaceId === arg;
750
+ const warning = isSelf
751
+ ? "\n⚠️ This will unlink this conversation. You'll need to link it to another space to continue chatting."
752
+ : "";
753
+ return `Delete space '${arg}' (${target.name})? This will destroy all messages, tasks, roles, config, and preferences for that space. Reply *yes* to confirm or *no* to cancel.${warning}`;
754
+ }
755
+
756
+ case "unlink": {
757
+ if (!conversationContext) {
758
+ return "Cannot determine current conversation.";
759
+ }
760
+ const convo = this.db.findConversation(
761
+ conversationContext.platform,
762
+ conversationContext.externalId,
763
+ );
764
+ if (!convo) return "Current conversation not found.";
765
+ this.db.unlinkConversation(convo.id);
766
+ return "Unlinked. Messages in this conversation will be ignored until you link it to a space with /spaces switch.";
767
+ }
768
+
769
+ default:
770
+ return `/spaces: unknown verb '${verb}'. Use /spaces for help.`;
771
+ }
772
+ }
773
+
774
+ private handlePendingSpaceDelete(
775
+ spaceId: string,
776
+ callerId: string,
777
+ text: string,
778
+ ): string | null {
779
+ const pendingId = this.db.getSpaceConfig(
780
+ spaceId,
781
+ "spaces.pending_delete_id",
782
+ );
783
+ if (!pendingId) return null;
784
+
785
+ const seededAdmins = this.config.admins
786
+ ? this.config.admins
787
+ .split(",")
788
+ .map((s) => s.trim())
789
+ .filter(Boolean)
790
+ : [];
791
+ if (!seededAdmins.includes(callerId)) return null;
792
+
793
+ const pendingAt = this.db.getSpaceConfig(
794
+ spaceId,
795
+ "spaces.pending_delete_at",
796
+ );
797
+ const ageMs = pendingAt
798
+ ? Date.now() - new Date(pendingAt).getTime()
799
+ : Number.POSITIVE_INFINITY;
800
+ const expired = Number.isNaN(ageMs) || ageMs > 60_000;
801
+
802
+ this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_id");
803
+ this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_at");
804
+
805
+ if (expired) {
806
+ if (text === "yes") return "Delete cancelled (timed out).";
807
+ return null;
808
+ }
809
+
810
+ if (text === "no") {
811
+ return "Delete cancelled.";
812
+ }
813
+
814
+ if (text === "yes") {
815
+ const target = this.db.getSpace(pendingId);
816
+ if (!target) return `Space '${pendingId}' not found.`;
817
+ const result = this.db.deleteSpace(pendingId);
818
+ if (!result.deleted) return `Failed to delete space '${pendingId}'.`;
819
+ return `Deleted space '${pendingId}' (${target.name}). Removed: ${result.removed.messages} messages, ${result.removed.tasks} tasks, ${result.removed.conversationsUnlinked} conversations unlinked.`;
820
+ }
821
+
822
+ return null;
823
+ }
824
+
825
+ private clearPendingDelete(spaceId: string): void {
826
+ this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_id");
827
+ this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_at");
828
+ }
829
+
830
+ private executePauseCommand(
831
+ spaceId: string,
832
+ callerId: string,
833
+ verb?: string,
834
+ ): string {
835
+ const alreadyPaused = this.db.getSpaceConfig(spaceId, "paused") === "true";
836
+ const hadTimer = this.db.getSpaceConfig(spaceId, "paused.resume_at");
837
+
838
+ if (verb) {
839
+ const parsed = this.parsePauseDuration(verb);
840
+ if (!parsed.ok) return parsed.error;
841
+
842
+ this.db.setSpaceConfig(spaceId, "paused", "true", callerId);
843
+ const resumeAt = Date.now() + parsed.ms;
844
+ this.db.setSpaceConfig(
845
+ spaceId,
846
+ "paused.resume_at",
847
+ String(resumeAt),
848
+ callerId,
849
+ );
850
+ this.schedulePauseTimer(spaceId, parsed.ms);
851
+
852
+ const resumeTime = new Date(resumeAt).toLocaleString("en-US", {
853
+ hour: "2-digit",
854
+ minute: "2-digit",
855
+ hour12: true,
856
+ });
857
+ return `Bot paused for ${verb}. It will auto-resume at ${resumeTime}.`;
858
+ }
859
+
860
+ if (alreadyPaused && !hadTimer) {
861
+ return "Already paused.";
862
+ }
863
+
864
+ if (alreadyPaused && hadTimer) {
865
+ this.clearPauseTimer(spaceId);
866
+ this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
867
+ return "Pause is now indefinite (timer cleared). Use /resume to reactivate.";
868
+ }
869
+
870
+ this.db.setSpaceConfig(spaceId, "paused", "true", callerId);
871
+ return "Bot paused in this space. Use /resume to reactivate.";
872
+ }
873
+
874
+ private executeResumeCommand(spaceId: string, _callerId: string): string {
875
+ if (this.db.getSpaceConfig(spaceId, "paused") !== "true") {
876
+ return "Bot is not paused.";
877
+ }
878
+ this.clearPauseTimer(spaceId);
879
+ this.db.deleteSpaceConfig(spaceId, "paused");
880
+ this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
881
+ return "Bot resumed.";
882
+ }
883
+
884
+ private parsePauseDuration(
885
+ input: string,
886
+ ): { ok: true; ms: number } | { ok: false; error: string } {
887
+ const match = input.match(/^(\d+)(m|h)$/i);
888
+ if (!match) {
889
+ return {
890
+ ok: false,
891
+ error: "Invalid duration. Use e.g. /pause 30m or /pause 2h.",
892
+ };
893
+ }
894
+ const value = Number.parseInt(match[1], 10);
895
+ const unit = match[2].toLowerCase();
896
+ const ms = unit === "h" ? value * 60 * 60 * 1000 : value * 60 * 1000;
897
+
898
+ if (ms < 60_000) {
899
+ return { ok: false, error: "Duration must be at least 1 minute." };
900
+ }
901
+ if (ms > 24 * 60 * 60 * 1000) {
902
+ return {
903
+ ok: false,
904
+ error: "Duration must be at most 24 hours.",
905
+ };
906
+ }
907
+ return { ok: true, ms };
908
+ }
909
+
910
+ private schedulePauseTimer(spaceId: string, delayMs: number): void {
911
+ this.clearPauseTimer(spaceId);
912
+ const timer = setTimeout(() => {
913
+ this.pauseTimers.delete(spaceId);
914
+ if (this.db.getSpaceConfig(spaceId, "paused") !== "true") return;
915
+ this.db.deleteSpaceConfig(spaceId, "paused");
916
+ this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
917
+ const text = "Bot resumed — pause timer expired.";
918
+ this.messageSender
919
+ ?.send(spaceId, text, [])
920
+ .catch((e) =>
921
+ logger.warn("Auto-resume notification failed", { spaceId, error: e }),
922
+ );
923
+ this.deliverTaskOutput(spaceId, text);
924
+ }, delayMs);
925
+ if (timer.unref) timer.unref();
926
+ this.pauseTimers.set(spaceId, timer);
927
+ }
928
+
929
+ private clearPauseTimer(spaceId: string): void {
930
+ const existing = this.pauseTimers.get(spaceId);
931
+ if (existing) {
932
+ clearTimeout(existing);
933
+ this.pauseTimers.delete(spaceId);
934
+ }
935
+ }
936
+
937
+ private restorePauseTimers(): void {
938
+ for (const space of this.db.listSpaces()) {
939
+ const resumeAtStr = this.db.getSpaceConfig(space.id, "paused.resume_at");
940
+ if (!resumeAtStr) continue;
941
+ const resumeAt = Number.parseInt(resumeAtStr, 10);
942
+ if (Number.isNaN(resumeAt)) continue;
943
+
944
+ const remaining = resumeAt - Date.now();
945
+ if (remaining <= 0) {
946
+ this.db.deleteSpaceConfig(space.id, "paused");
947
+ this.db.deleteSpaceConfig(space.id, "paused.resume_at");
948
+ const text = "Bot resumed — pause timer expired.";
949
+ this.messageSender?.send(space.id, text, []).catch((e) =>
950
+ logger.warn("Auto-resume notification failed", {
951
+ spaceId: space.id,
952
+ error: e,
953
+ }),
954
+ );
955
+ this.deliverTaskOutput(space.id, text);
956
+ } else {
957
+ this.schedulePauseTimer(space.id, remaining);
958
+ }
959
+ }
960
+ }
961
+
596
962
  onShutdown(hook: ShutdownHook): void {
597
963
  this.shutdownHooks.push(hook);
598
964
  }
@@ -645,9 +1011,11 @@ export class MercuryCoreRuntime {
645
1011
  if (forceTimer.unref) forceTimer.unref();
646
1012
 
647
1013
  try {
648
- // 1. Stop schedulers
1014
+ // 1. Stop schedulers + pause timers
649
1015
  logger.info("Shutdown: stopping task scheduler");
650
1016
  this.scheduler.stop();
1017
+ for (const timer of this.pauseTimers.values()) clearTimeout(timer);
1018
+ this.pauseTimers.clear();
651
1019
 
652
1020
  // 2. Drain queue — cancel pending, wait for active
653
1021
  logger.info("Shutdown: draining group queue");
package/src/storage/db.ts CHANGED
@@ -197,6 +197,15 @@ export class Db {
197
197
 
198
198
  CREATE INDEX IF NOT EXISTS idx_mpi_mercury_id
199
199
  ON message_platform_ids(mercury_message_id);
200
+
201
+ CREATE TABLE IF NOT EXISTS daily_rate_usage (
202
+ space_id TEXT NOT NULL,
203
+ platform_user_id TEXT NOT NULL,
204
+ date TEXT NOT NULL,
205
+ count INTEGER NOT NULL DEFAULT 0,
206
+ updated_at INTEGER NOT NULL,
207
+ PRIMARY KEY (space_id, platform_user_id, date)
208
+ );
200
209
  `);
201
210
  this.ensureMessagesRunMetaColumn();
202
211
  this.ensureChatStateClearBoundaryColumn();
@@ -385,6 +394,7 @@ export class Db {
385
394
  config: number;
386
395
  preferences: number;
387
396
  tokenUsage: number;
397
+ dailyRateUsage: number;
388
398
  conversationsUnlinked: number;
389
399
  };
390
400
  } {
@@ -395,6 +405,12 @@ export class Db {
395
405
  "DELETE FROM message_platform_ids WHERE mercury_message_id IN (SELECT id FROM messages WHERE space_id = ?)",
396
406
  )
397
407
  .run(spaceId);
408
+ this.db
409
+ .query("DELETE FROM extension_state WHERE space_id = ?")
410
+ .run(spaceId);
411
+ const dailyRateUsage = this.db
412
+ .query("DELETE FROM daily_rate_usage WHERE space_id = ?")
413
+ .run(spaceId).changes;
398
414
  const messages = this.db
399
415
  .query("DELETE FROM messages WHERE space_id = ?")
400
416
  .run(spaceId).changes;
@@ -436,6 +452,7 @@ export class Db {
436
452
  config,
437
453
  preferences,
438
454
  tokenUsage,
455
+ dailyRateUsage,
439
456
  conversationsUnlinked: Number(conversationsUnlinked?.count ?? 0),
440
457
  },
441
458
  };
@@ -1522,6 +1539,51 @@ export class Db {
1522
1539
  }));
1523
1540
  }
1524
1541
 
1542
+ // ─── Daily Rate Usage ─────────────────────────────────────────────────
1543
+
1544
+ checkAndIncrementDailyUsage(
1545
+ spaceId: string,
1546
+ userId: string,
1547
+ limit: number,
1548
+ ): { allowed: boolean; count: number } {
1549
+ const today = new Date().toISOString().slice(0, 10);
1550
+ const now = Date.now();
1551
+
1552
+ this.db
1553
+ .query(
1554
+ "DELETE FROM daily_rate_usage WHERE space_id = ? AND platform_user_id = ? AND date < ?",
1555
+ )
1556
+ .run(spaceId, userId, today);
1557
+
1558
+ // Atomic: insert with count=1 if no row exists, or increment only if under limit.
1559
+ const result = this.db
1560
+ .query(
1561
+ `INSERT INTO daily_rate_usage (space_id, platform_user_id, date, count, updated_at)
1562
+ VALUES (?, ?, ?, 1, ?)
1563
+ ON CONFLICT(space_id, platform_user_id, date)
1564
+ DO UPDATE SET count = count + 1, updated_at = excluded.updated_at
1565
+ WHERE count < ?`,
1566
+ )
1567
+ .run(spaceId, userId, today, now, limit);
1568
+
1569
+ if (result.changes > 0) {
1570
+ const row = this.db
1571
+ .query(
1572
+ "SELECT count FROM daily_rate_usage WHERE space_id = ? AND platform_user_id = ? AND date = ?",
1573
+ )
1574
+ .get(spaceId, userId, today) as { count: number };
1575
+ return { allowed: true, count: row.count };
1576
+ }
1577
+
1578
+ // No changes = either at limit or above
1579
+ const row = this.db
1580
+ .query(
1581
+ "SELECT count FROM daily_rate_usage WHERE space_id = ? AND platform_user_id = ? AND date = ?",
1582
+ )
1583
+ .get(spaceId, userId, today) as { count: number } | null;
1584
+ return { allowed: false, count: row?.count ?? 0 };
1585
+ }
1586
+
1525
1587
  // ─── Token Usage ──────────────────────────────────────────────────────
1526
1588
 
1527
1589
  recordUsage(spaceId: string, usage: TokenUsage): void {