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 +1 -16
- package/docs/rate-limiting.md +70 -7
- package/package.json +1 -1
- package/src/cli/mercury.ts +2 -3
- package/src/core/commands.ts +110 -76
- package/src/core/router.ts +13 -0
- package/src/core/routes/dashboard.ts +3 -1
- package/src/core/runtime.ts +370 -2
- package/src/storage/db.ts +62 -0
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:**
|
package/docs/rate-limiting.md
CHANGED
|
@@ -11,15 +11,19 @@ Message received
|
|
|
11
11
|
│
|
|
12
12
|
├─► Type = "assistant"?
|
|
13
13
|
│ │
|
|
14
|
-
│ ├─►
|
|
15
|
-
│ │ •
|
|
16
|
-
│ │ •
|
|
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
|
-
│ ├─►
|
|
20
|
-
│
|
|
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
|
|
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
package/src/cli/mercury.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2114
|
+
copyDirRecursive(userExtDir, join(absOutput, "extensions"));
|
|
2116
2115
|
}
|
|
2117
2116
|
|
|
2118
2117
|
// Generate manifest
|
package/src/core/commands.ts
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
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
|
+
}
|
package/src/core/router.ts
CHANGED
|
@@ -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">
|
package/src/core/runtime.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 {
|