mercury-agent 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- package/src/types.ts +295 -0
|
@@ -0,0 +1,1624 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
Conversation,
|
|
6
|
+
MessageAttachment,
|
|
7
|
+
MessageRunMeta,
|
|
8
|
+
ScheduledTask,
|
|
9
|
+
Space,
|
|
10
|
+
SpaceConfigEntry,
|
|
11
|
+
SpacePreferenceEntry,
|
|
12
|
+
SpaceRole,
|
|
13
|
+
StoredMessage,
|
|
14
|
+
TokenUsage,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
|
|
17
|
+
type SpaceRow = {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
tags: string | null;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
updatedAt: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type ConversationRow = {
|
|
26
|
+
id: number;
|
|
27
|
+
platform: string;
|
|
28
|
+
externalId: string;
|
|
29
|
+
kind: string;
|
|
30
|
+
observedTitle: string | null;
|
|
31
|
+
spaceId: string | null;
|
|
32
|
+
firstSeenAt: number;
|
|
33
|
+
lastSeenAt: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type MessageRow = {
|
|
37
|
+
id: number;
|
|
38
|
+
spaceId: string;
|
|
39
|
+
role: StoredMessage["role"];
|
|
40
|
+
content: string;
|
|
41
|
+
attachments: string | null;
|
|
42
|
+
runMeta: string | null;
|
|
43
|
+
replyToId: number | null;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const SPACE_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
49
|
+
|
|
50
|
+
export class Db {
|
|
51
|
+
private readonly db: Database;
|
|
52
|
+
|
|
53
|
+
constructor(dbPath: string) {
|
|
54
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
55
|
+
this.db = new Database(dbPath, { create: true });
|
|
56
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
57
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
58
|
+
this.migrate();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private migrate() {
|
|
62
|
+
this.db.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS spaces (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
name TEXT NOT NULL,
|
|
66
|
+
tags TEXT,
|
|
67
|
+
created_at INTEGER NOT NULL,
|
|
68
|
+
updated_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
72
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
73
|
+
platform TEXT NOT NULL,
|
|
74
|
+
external_id TEXT NOT NULL,
|
|
75
|
+
kind TEXT NOT NULL DEFAULT 'group',
|
|
76
|
+
observed_title TEXT,
|
|
77
|
+
space_id TEXT,
|
|
78
|
+
first_seen_at INTEGER NOT NULL,
|
|
79
|
+
last_seen_at INTEGER NOT NULL,
|
|
80
|
+
UNIQUE(platform, external_id),
|
|
81
|
+
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE SET NULL
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
space_id TEXT NOT NULL,
|
|
87
|
+
role TEXT NOT NULL,
|
|
88
|
+
content TEXT NOT NULL,
|
|
89
|
+
attachments TEXT,
|
|
90
|
+
created_at INTEGER NOT NULL,
|
|
91
|
+
updated_at INTEGER NOT NULL
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_messages_space_created
|
|
95
|
+
ON messages(space_id, created_at);
|
|
96
|
+
|
|
97
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
98
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
99
|
+
space_id TEXT NOT NULL,
|
|
100
|
+
cron TEXT,
|
|
101
|
+
at TEXT,
|
|
102
|
+
prompt TEXT NOT NULL,
|
|
103
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
104
|
+
silent INTEGER NOT NULL DEFAULT 0,
|
|
105
|
+
next_run_at INTEGER NOT NULL,
|
|
106
|
+
created_by TEXT NOT NULL DEFAULT 'system',
|
|
107
|
+
created_at INTEGER NOT NULL,
|
|
108
|
+
updated_at INTEGER NOT NULL
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_next
|
|
112
|
+
ON tasks(active, next_run_at);
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS chat_state (
|
|
115
|
+
space_id TEXT PRIMARY KEY,
|
|
116
|
+
min_message_id INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
created_at INTEGER NOT NULL,
|
|
118
|
+
updated_at INTEGER NOT NULL
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
CREATE TABLE IF NOT EXISTS space_roles (
|
|
122
|
+
space_id TEXT NOT NULL,
|
|
123
|
+
platform_user_id TEXT NOT NULL,
|
|
124
|
+
role TEXT NOT NULL,
|
|
125
|
+
granted_by TEXT,
|
|
126
|
+
created_at INTEGER NOT NULL,
|
|
127
|
+
updated_at INTEGER NOT NULL,
|
|
128
|
+
PRIMARY KEY (space_id, platform_user_id)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
CREATE TABLE IF NOT EXISTS space_config (
|
|
132
|
+
space_id TEXT NOT NULL,
|
|
133
|
+
key TEXT NOT NULL,
|
|
134
|
+
value TEXT NOT NULL,
|
|
135
|
+
updated_by TEXT,
|
|
136
|
+
created_at INTEGER NOT NULL,
|
|
137
|
+
updated_at INTEGER NOT NULL,
|
|
138
|
+
PRIMARY KEY (space_id, key)
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
CREATE TABLE IF NOT EXISTS space_preferences (
|
|
142
|
+
space_id TEXT NOT NULL,
|
|
143
|
+
key TEXT NOT NULL,
|
|
144
|
+
value TEXT NOT NULL,
|
|
145
|
+
created_by TEXT NOT NULL,
|
|
146
|
+
created_at INTEGER NOT NULL,
|
|
147
|
+
updated_at INTEGER NOT NULL,
|
|
148
|
+
PRIMARY KEY (space_id, key)
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
CREATE TABLE IF NOT EXISTS extension_state (
|
|
152
|
+
extension TEXT NOT NULL,
|
|
153
|
+
key TEXT NOT NULL,
|
|
154
|
+
value TEXT NOT NULL,
|
|
155
|
+
created_at INTEGER NOT NULL,
|
|
156
|
+
updated_at INTEGER NOT NULL,
|
|
157
|
+
PRIMARY KEY (extension, key)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
CREATE TABLE IF NOT EXISTS mutes (
|
|
161
|
+
space_id TEXT NOT NULL,
|
|
162
|
+
platform_user_id TEXT NOT NULL,
|
|
163
|
+
expires_at INTEGER NOT NULL,
|
|
164
|
+
reason TEXT,
|
|
165
|
+
muted_by TEXT NOT NULL,
|
|
166
|
+
created_at INTEGER NOT NULL,
|
|
167
|
+
PRIMARY KEY (space_id, platform_user_id)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
CREATE TABLE IF NOT EXISTS token_usage (
|
|
171
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
172
|
+
space_id TEXT NOT NULL,
|
|
173
|
+
input_tokens INTEGER,
|
|
174
|
+
output_tokens INTEGER,
|
|
175
|
+
total_tokens INTEGER,
|
|
176
|
+
cache_read_tokens INTEGER,
|
|
177
|
+
cache_write_tokens INTEGER,
|
|
178
|
+
cost REAL,
|
|
179
|
+
model TEXT,
|
|
180
|
+
provider TEXT,
|
|
181
|
+
created_at INTEGER NOT NULL
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_token_usage_space
|
|
185
|
+
ON token_usage(space_id, created_at);
|
|
186
|
+
|
|
187
|
+
CREATE TABLE IF NOT EXISTS message_platform_ids (
|
|
188
|
+
mercury_message_id INTEGER NOT NULL,
|
|
189
|
+
platform TEXT NOT NULL,
|
|
190
|
+
conversation_external_id TEXT NOT NULL,
|
|
191
|
+
platform_message_id TEXT NOT NULL,
|
|
192
|
+
created_at INTEGER NOT NULL,
|
|
193
|
+
PRIMARY KEY (platform, conversation_external_id, platform_message_id),
|
|
194
|
+
FOREIGN KEY (mercury_message_id) REFERENCES messages(id)
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_mpi_mercury_id
|
|
198
|
+
ON message_platform_ids(mercury_message_id);
|
|
199
|
+
`);
|
|
200
|
+
this.ensureMessagesRunMetaColumn();
|
|
201
|
+
this.ensureChatStateClearBoundaryColumn();
|
|
202
|
+
this.ensureMessagesReplyToIdColumn();
|
|
203
|
+
this.ensureSpaceRolesDisplayNameColumn();
|
|
204
|
+
this.ensureTasksTimezoneColumn();
|
|
205
|
+
this.ensureTasksNameColumn();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private ensureMessagesRunMetaColumn(): void {
|
|
209
|
+
const cols = this.db.query("PRAGMA table_info(messages)").all() as {
|
|
210
|
+
name: string;
|
|
211
|
+
}[];
|
|
212
|
+
if (cols.some((c) => c.name === "run_meta")) return;
|
|
213
|
+
this.db.exec("ALTER TABLE messages ADD COLUMN run_meta TEXT");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private ensureChatStateClearBoundaryColumn(): void {
|
|
217
|
+
const cols = this.db.query("PRAGMA table_info(chat_state)").all() as {
|
|
218
|
+
name: string;
|
|
219
|
+
}[];
|
|
220
|
+
if (cols.some((c) => c.name === "clear_boundary")) return;
|
|
221
|
+
this.db.exec(
|
|
222
|
+
"ALTER TABLE chat_state ADD COLUMN clear_boundary INTEGER NOT NULL DEFAULT 0",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private ensureMessagesReplyToIdColumn(): void {
|
|
227
|
+
const cols = this.db.query("PRAGMA table_info(messages)").all() as {
|
|
228
|
+
name: string;
|
|
229
|
+
}[];
|
|
230
|
+
if (cols.some((c) => c.name === "reply_to_id")) return;
|
|
231
|
+
this.db.exec(
|
|
232
|
+
"ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private ensureSpaceRolesDisplayNameColumn(): void {
|
|
237
|
+
const cols = this.db.query("PRAGMA table_info(space_roles)").all() as {
|
|
238
|
+
name: string;
|
|
239
|
+
}[];
|
|
240
|
+
if (cols.some((c) => c.name === "display_name")) return;
|
|
241
|
+
this.db.exec("ALTER TABLE space_roles ADD COLUMN display_name TEXT");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private ensureTasksTimezoneColumn(): void {
|
|
245
|
+
const cols = this.db.query("PRAGMA table_info(tasks)").all() as {
|
|
246
|
+
name: string;
|
|
247
|
+
}[];
|
|
248
|
+
if (cols.some((c) => c.name === "timezone")) return;
|
|
249
|
+
this.db.exec("ALTER TABLE tasks ADD COLUMN timezone TEXT");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private ensureTasksNameColumn(): void {
|
|
253
|
+
const cols = this.db.query("PRAGMA table_info(tasks)").all() as {
|
|
254
|
+
name: string;
|
|
255
|
+
}[];
|
|
256
|
+
if (cols.some((c) => c.name === "name")) return;
|
|
257
|
+
this.db.exec("ALTER TABLE tasks ADD COLUMN name TEXT");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private assertValidSpaceId(spaceId: string): void {
|
|
261
|
+
if (!SPACE_ID_RE.test(spaceId)) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Invalid space id '${spaceId}'. Must match ${SPACE_ID_RE.toString()}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private parseMessageRow(row: MessageRow): StoredMessage {
|
|
269
|
+
let attachments: MessageAttachment[] | undefined;
|
|
270
|
+
if (row.attachments) {
|
|
271
|
+
try {
|
|
272
|
+
attachments = JSON.parse(row.attachments) as MessageAttachment[];
|
|
273
|
+
} catch {
|
|
274
|
+
attachments = undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
let runMeta: MessageRunMeta | undefined;
|
|
278
|
+
if (row.runMeta) {
|
|
279
|
+
try {
|
|
280
|
+
runMeta = JSON.parse(row.runMeta) as MessageRunMeta;
|
|
281
|
+
} catch {
|
|
282
|
+
runMeta = undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
id: row.id,
|
|
287
|
+
spaceId: row.spaceId,
|
|
288
|
+
role: row.role,
|
|
289
|
+
content: row.content,
|
|
290
|
+
attachments,
|
|
291
|
+
createdAt: row.createdAt,
|
|
292
|
+
updatedAt: row.updatedAt,
|
|
293
|
+
runMeta,
|
|
294
|
+
replyToId: row.replyToId ?? undefined,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
createSpace(id: string, name: string, tags?: string): Space {
|
|
299
|
+
this.assertValidSpaceId(id);
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
|
|
302
|
+
const result = this.db
|
|
303
|
+
.query(
|
|
304
|
+
`INSERT OR IGNORE INTO spaces(id, name, tags, created_at, updated_at)
|
|
305
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
306
|
+
)
|
|
307
|
+
.run(id, name, tags ?? null, now, now);
|
|
308
|
+
|
|
309
|
+
if (result.changes === 0) {
|
|
310
|
+
throw new Error(`Space already exists: ${id}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const row = this.db
|
|
314
|
+
.query(
|
|
315
|
+
`SELECT id, name, tags, created_at as createdAt, updated_at as updatedAt
|
|
316
|
+
FROM spaces WHERE id = ?`,
|
|
317
|
+
)
|
|
318
|
+
.get(id) as SpaceRow | null;
|
|
319
|
+
|
|
320
|
+
if (!row) throw new Error(`Failed to load space ${id}`);
|
|
321
|
+
return row;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
ensureSpace(spaceId: string): Space {
|
|
325
|
+
this.assertValidSpaceId(spaceId);
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
|
|
328
|
+
this.db
|
|
329
|
+
.query(
|
|
330
|
+
`INSERT OR IGNORE INTO spaces(id, name, tags, created_at, updated_at)
|
|
331
|
+
VALUES (?, ?, NULL, ?, ?)`,
|
|
332
|
+
)
|
|
333
|
+
.run(spaceId, spaceId, now, now);
|
|
334
|
+
|
|
335
|
+
this.db
|
|
336
|
+
.query("UPDATE spaces SET updated_at = ? WHERE id = ?")
|
|
337
|
+
.run(now, spaceId);
|
|
338
|
+
|
|
339
|
+
const row = this.db
|
|
340
|
+
.query(
|
|
341
|
+
`SELECT id, name, tags, created_at as createdAt, updated_at as updatedAt
|
|
342
|
+
FROM spaces WHERE id = ?`,
|
|
343
|
+
)
|
|
344
|
+
.get(spaceId) as SpaceRow | null;
|
|
345
|
+
|
|
346
|
+
if (!row) throw new Error(`Failed to load space ${spaceId}`);
|
|
347
|
+
return row;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
listSpaces(): Space[] {
|
|
351
|
+
return this.db
|
|
352
|
+
.query(
|
|
353
|
+
`SELECT id, name, tags, created_at as createdAt, updated_at as updatedAt
|
|
354
|
+
FROM spaces ORDER BY created_at ASC`,
|
|
355
|
+
)
|
|
356
|
+
.all() as Space[];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
getSpace(spaceId: string): Space | null {
|
|
360
|
+
return this.db
|
|
361
|
+
.query(
|
|
362
|
+
`SELECT id, name, tags, created_at as createdAt, updated_at as updatedAt
|
|
363
|
+
FROM spaces WHERE id = ?`,
|
|
364
|
+
)
|
|
365
|
+
.get(spaceId) as Space | null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
updateSpaceName(spaceId: string, name: string): boolean {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
const result = this.db
|
|
371
|
+
.query("UPDATE spaces SET name = ?, updated_at = ? WHERE id = ?")
|
|
372
|
+
.run(name, now, spaceId);
|
|
373
|
+
return result.changes > 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
deleteSpace(spaceId: string): {
|
|
377
|
+
deleted: boolean;
|
|
378
|
+
removed: {
|
|
379
|
+
space: number;
|
|
380
|
+
messages: number;
|
|
381
|
+
tasks: number;
|
|
382
|
+
chatState: number;
|
|
383
|
+
roles: number;
|
|
384
|
+
config: number;
|
|
385
|
+
preferences: number;
|
|
386
|
+
tokenUsage: number;
|
|
387
|
+
conversationsUnlinked: number;
|
|
388
|
+
};
|
|
389
|
+
} {
|
|
390
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
391
|
+
try {
|
|
392
|
+
this.db
|
|
393
|
+
.query(
|
|
394
|
+
"DELETE FROM message_platform_ids WHERE mercury_message_id IN (SELECT id FROM messages WHERE space_id = ?)",
|
|
395
|
+
)
|
|
396
|
+
.run(spaceId);
|
|
397
|
+
const messages = this.db
|
|
398
|
+
.query("DELETE FROM messages WHERE space_id = ?")
|
|
399
|
+
.run(spaceId).changes;
|
|
400
|
+
const tasks = this.db
|
|
401
|
+
.query("DELETE FROM tasks WHERE space_id = ?")
|
|
402
|
+
.run(spaceId).changes;
|
|
403
|
+
const chatState = this.db
|
|
404
|
+
.query("DELETE FROM chat_state WHERE space_id = ?")
|
|
405
|
+
.run(spaceId).changes;
|
|
406
|
+
const roles = this.db
|
|
407
|
+
.query("DELETE FROM space_roles WHERE space_id = ?")
|
|
408
|
+
.run(spaceId).changes;
|
|
409
|
+
const config = this.db
|
|
410
|
+
.query("DELETE FROM space_config WHERE space_id = ?")
|
|
411
|
+
.run(spaceId).changes;
|
|
412
|
+
const preferences = this.db
|
|
413
|
+
.query("DELETE FROM space_preferences WHERE space_id = ?")
|
|
414
|
+
.run(spaceId).changes;
|
|
415
|
+
const tokenUsage = this.db
|
|
416
|
+
.query("DELETE FROM token_usage WHERE space_id = ?")
|
|
417
|
+
.run(spaceId).changes;
|
|
418
|
+
const conversationsUnlinked = this.db
|
|
419
|
+
.query("SELECT COUNT(*) as count FROM conversations WHERE space_id = ?")
|
|
420
|
+
.get(spaceId) as { count: number };
|
|
421
|
+
const space = this.db
|
|
422
|
+
.query("DELETE FROM spaces WHERE id = ?")
|
|
423
|
+
.run(spaceId).changes;
|
|
424
|
+
|
|
425
|
+
this.db.exec("COMMIT");
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
deleted: space > 0,
|
|
429
|
+
removed: {
|
|
430
|
+
space,
|
|
431
|
+
messages,
|
|
432
|
+
tasks,
|
|
433
|
+
chatState,
|
|
434
|
+
roles,
|
|
435
|
+
config,
|
|
436
|
+
preferences,
|
|
437
|
+
tokenUsage,
|
|
438
|
+
conversationsUnlinked: Number(conversationsUnlinked?.count ?? 0),
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
} catch (error) {
|
|
442
|
+
this.db.exec("ROLLBACK");
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
clearSpaceAttachments(spaceId: string): number {
|
|
448
|
+
const now = Date.now();
|
|
449
|
+
return this.db
|
|
450
|
+
.query(
|
|
451
|
+
"UPDATE messages SET attachments = NULL, updated_at = ? WHERE space_id = ? AND attachments IS NOT NULL",
|
|
452
|
+
)
|
|
453
|
+
.run(now, spaceId).changes;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
ensureConversation(
|
|
457
|
+
platform: string,
|
|
458
|
+
externalId: string,
|
|
459
|
+
kind: string,
|
|
460
|
+
observedTitle?: string,
|
|
461
|
+
): Conversation {
|
|
462
|
+
const now = Date.now();
|
|
463
|
+
|
|
464
|
+
this.db
|
|
465
|
+
.query(
|
|
466
|
+
`INSERT OR IGNORE INTO conversations(
|
|
467
|
+
platform, external_id, kind, observed_title, space_id, first_seen_at, last_seen_at
|
|
468
|
+
) VALUES (?, ?, ?, ?, NULL, ?, ?)`,
|
|
469
|
+
)
|
|
470
|
+
.run(platform, externalId, kind, observedTitle ?? null, now, now);
|
|
471
|
+
|
|
472
|
+
if (observedTitle?.trim()) {
|
|
473
|
+
this.db
|
|
474
|
+
.query(
|
|
475
|
+
`UPDATE conversations
|
|
476
|
+
SET kind = ?,
|
|
477
|
+
observed_title = ?,
|
|
478
|
+
last_seen_at = ?
|
|
479
|
+
WHERE platform = ? AND external_id = ?`,
|
|
480
|
+
)
|
|
481
|
+
.run(kind, observedTitle, now, platform, externalId);
|
|
482
|
+
} else {
|
|
483
|
+
this.db
|
|
484
|
+
.query(
|
|
485
|
+
`UPDATE conversations
|
|
486
|
+
SET kind = ?, last_seen_at = ?
|
|
487
|
+
WHERE platform = ? AND external_id = ?`,
|
|
488
|
+
)
|
|
489
|
+
.run(kind, now, platform, externalId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const row = this.db
|
|
493
|
+
.query(
|
|
494
|
+
`SELECT
|
|
495
|
+
id,
|
|
496
|
+
platform,
|
|
497
|
+
external_id as externalId,
|
|
498
|
+
kind,
|
|
499
|
+
observed_title as observedTitle,
|
|
500
|
+
space_id as spaceId,
|
|
501
|
+
first_seen_at as firstSeenAt,
|
|
502
|
+
last_seen_at as lastSeenAt
|
|
503
|
+
FROM conversations
|
|
504
|
+
WHERE platform = ? AND external_id = ?`,
|
|
505
|
+
)
|
|
506
|
+
.get(platform, externalId) as ConversationRow | null;
|
|
507
|
+
|
|
508
|
+
if (!row) {
|
|
509
|
+
throw new Error(`Failed to load conversation ${platform}:${externalId}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return row;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
findConversation(platform: string, externalId: string): Conversation | null {
|
|
516
|
+
return this.db
|
|
517
|
+
.query(
|
|
518
|
+
`SELECT
|
|
519
|
+
id,
|
|
520
|
+
platform,
|
|
521
|
+
external_id as externalId,
|
|
522
|
+
kind,
|
|
523
|
+
observed_title as observedTitle,
|
|
524
|
+
space_id as spaceId,
|
|
525
|
+
first_seen_at as firstSeenAt,
|
|
526
|
+
last_seen_at as lastSeenAt
|
|
527
|
+
FROM conversations
|
|
528
|
+
WHERE platform = ? AND external_id = ?`,
|
|
529
|
+
)
|
|
530
|
+
.get(platform, externalId) as Conversation | null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
findConversationsByPlatformPrefix(
|
|
534
|
+
platform: string,
|
|
535
|
+
prefix: string,
|
|
536
|
+
): Conversation[] {
|
|
537
|
+
const escapedPrefix = prefix
|
|
538
|
+
.replace(/\\/g, "\\\\")
|
|
539
|
+
.replace(/%/g, "\\%")
|
|
540
|
+
.replace(/_/g, "\\_");
|
|
541
|
+
return this.db
|
|
542
|
+
.query(
|
|
543
|
+
`SELECT
|
|
544
|
+
id,
|
|
545
|
+
platform,
|
|
546
|
+
external_id as externalId,
|
|
547
|
+
kind,
|
|
548
|
+
observed_title as observedTitle,
|
|
549
|
+
space_id as spaceId,
|
|
550
|
+
first_seen_at as firstSeenAt,
|
|
551
|
+
last_seen_at as lastSeenAt
|
|
552
|
+
FROM conversations
|
|
553
|
+
WHERE platform = ? AND (external_id = ? OR external_id LIKE ? ESCAPE '\\')`,
|
|
554
|
+
)
|
|
555
|
+
.all(platform, prefix, `${escapedPrefix}:%`) as Conversation[];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
listConversations(filter?: {
|
|
559
|
+
linked?: boolean;
|
|
560
|
+
platform?: string;
|
|
561
|
+
}): Conversation[] {
|
|
562
|
+
const where: string[] = [];
|
|
563
|
+
const params: Array<string | number> = [];
|
|
564
|
+
|
|
565
|
+
if (filter?.linked === true) {
|
|
566
|
+
where.push("space_id IS NOT NULL");
|
|
567
|
+
} else if (filter?.linked === false) {
|
|
568
|
+
where.push("space_id IS NULL");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (filter?.platform) {
|
|
572
|
+
where.push("platform = ?");
|
|
573
|
+
params.push(filter.platform);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
577
|
+
|
|
578
|
+
return this.db
|
|
579
|
+
.query(
|
|
580
|
+
`SELECT
|
|
581
|
+
id,
|
|
582
|
+
platform,
|
|
583
|
+
external_id as externalId,
|
|
584
|
+
kind,
|
|
585
|
+
observed_title as observedTitle,
|
|
586
|
+
space_id as spaceId,
|
|
587
|
+
first_seen_at as firstSeenAt,
|
|
588
|
+
last_seen_at as lastSeenAt
|
|
589
|
+
FROM conversations
|
|
590
|
+
${whereSql}
|
|
591
|
+
ORDER BY last_seen_at DESC, id DESC`,
|
|
592
|
+
)
|
|
593
|
+
.all(...params) as Conversation[];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
linkConversation(conversationId: number, spaceId: string): boolean {
|
|
597
|
+
const result = this.db
|
|
598
|
+
.query(
|
|
599
|
+
`UPDATE conversations
|
|
600
|
+
SET space_id = ?, last_seen_at = ?
|
|
601
|
+
WHERE id = ?`,
|
|
602
|
+
)
|
|
603
|
+
.run(spaceId, Date.now(), conversationId);
|
|
604
|
+
return result.changes > 0;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
unlinkConversation(conversationId: number): boolean {
|
|
608
|
+
const result = this.db
|
|
609
|
+
.query(
|
|
610
|
+
`UPDATE conversations
|
|
611
|
+
SET space_id = NULL, last_seen_at = ?
|
|
612
|
+
WHERE id = ?`,
|
|
613
|
+
)
|
|
614
|
+
.run(Date.now(), conversationId);
|
|
615
|
+
return result.changes > 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
getSpaceConversations(spaceId: string): Conversation[] {
|
|
619
|
+
return this.db
|
|
620
|
+
.query(
|
|
621
|
+
`SELECT
|
|
622
|
+
id,
|
|
623
|
+
platform,
|
|
624
|
+
external_id as externalId,
|
|
625
|
+
kind,
|
|
626
|
+
observed_title as observedTitle,
|
|
627
|
+
space_id as spaceId,
|
|
628
|
+
first_seen_at as firstSeenAt,
|
|
629
|
+
last_seen_at as lastSeenAt
|
|
630
|
+
FROM conversations
|
|
631
|
+
WHERE space_id = ?
|
|
632
|
+
ORDER BY last_seen_at DESC, id DESC`,
|
|
633
|
+
)
|
|
634
|
+
.all(spaceId) as Conversation[];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
updateConversationTitle(conversationId: number, title: string): void {
|
|
638
|
+
this.db
|
|
639
|
+
.query(
|
|
640
|
+
`UPDATE conversations
|
|
641
|
+
SET observed_title = ?, last_seen_at = ?
|
|
642
|
+
WHERE id = ?`,
|
|
643
|
+
)
|
|
644
|
+
.run(title, Date.now(), conversationId);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** True if the space has at least one linked non-DM conversation (group, channel, thread). */
|
|
648
|
+
hasGroupLinkedConversation(spaceId: string): boolean {
|
|
649
|
+
const row = this.db
|
|
650
|
+
.query(
|
|
651
|
+
"SELECT 1 FROM conversations WHERE space_id = ? AND kind != 'dm' LIMIT 1",
|
|
652
|
+
)
|
|
653
|
+
.get(spaceId);
|
|
654
|
+
return row !== null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
addMessage(
|
|
658
|
+
spaceId: string,
|
|
659
|
+
role: StoredMessage["role"],
|
|
660
|
+
content: string,
|
|
661
|
+
attachments?: MessageAttachment[],
|
|
662
|
+
replyToId?: number,
|
|
663
|
+
): number {
|
|
664
|
+
const now = Date.now();
|
|
665
|
+
const attachmentsJson =
|
|
666
|
+
attachments && attachments.length > 0
|
|
667
|
+
? JSON.stringify(attachments)
|
|
668
|
+
: null;
|
|
669
|
+
this.db
|
|
670
|
+
.query(
|
|
671
|
+
`INSERT INTO messages(space_id, role, content, attachments, reply_to_id, created_at, updated_at)
|
|
672
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
673
|
+
)
|
|
674
|
+
.run(
|
|
675
|
+
spaceId,
|
|
676
|
+
role,
|
|
677
|
+
content,
|
|
678
|
+
attachmentsJson,
|
|
679
|
+
replyToId ?? null,
|
|
680
|
+
now,
|
|
681
|
+
now,
|
|
682
|
+
);
|
|
683
|
+
const row = this.db.query("SELECT last_insert_rowid() as id").get() as {
|
|
684
|
+
id: number;
|
|
685
|
+
} | null;
|
|
686
|
+
if (!row) throw new Error("Failed to read message id");
|
|
687
|
+
return Number(row.id);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ── Platform message ID mapping (for reply-chain tracking) ────────────
|
|
691
|
+
|
|
692
|
+
addPlatformMessageId(
|
|
693
|
+
mercuryMessageId: number,
|
|
694
|
+
platform: string,
|
|
695
|
+
conversationExternalId: string,
|
|
696
|
+
platformMessageId: string,
|
|
697
|
+
): void {
|
|
698
|
+
this.db
|
|
699
|
+
.query(
|
|
700
|
+
`INSERT OR IGNORE INTO message_platform_ids(mercury_message_id, platform, conversation_external_id, platform_message_id, created_at)
|
|
701
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
702
|
+
)
|
|
703
|
+
.run(
|
|
704
|
+
mercuryMessageId,
|
|
705
|
+
platform,
|
|
706
|
+
conversationExternalId,
|
|
707
|
+
platformMessageId,
|
|
708
|
+
Date.now(),
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
lookupMercuryMessageId(
|
|
713
|
+
platform: string,
|
|
714
|
+
conversationExternalId: string,
|
|
715
|
+
platformMessageId: string,
|
|
716
|
+
): number | null {
|
|
717
|
+
const row = this.db
|
|
718
|
+
.query(
|
|
719
|
+
`SELECT mercury_message_id FROM message_platform_ids
|
|
720
|
+
WHERE platform = ? AND conversation_external_id = ? AND platform_message_id = ?`,
|
|
721
|
+
)
|
|
722
|
+
.get(platform, conversationExternalId, platformMessageId) as {
|
|
723
|
+
mercury_message_id: number;
|
|
724
|
+
} | null;
|
|
725
|
+
return row ? row.mercury_message_id : null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Walk reply_to_id pointers backward from a message, collecting up to
|
|
730
|
+
* maxDepth turns (each turn = user + assistant pair). Returns messages
|
|
731
|
+
* in chronological order (oldest first).
|
|
732
|
+
*/
|
|
733
|
+
getReplyChain(
|
|
734
|
+
messageId: number,
|
|
735
|
+
maxDepth: number,
|
|
736
|
+
spaceId?: string,
|
|
737
|
+
): StoredMessage[] {
|
|
738
|
+
const chain: StoredMessage[] = [];
|
|
739
|
+
let currentId: number | null = messageId;
|
|
740
|
+
const maxMessages = maxDepth * 2; // each turn = user + assistant
|
|
741
|
+
const boundary = spaceId ? this.getSessionBoundary(spaceId) : 0;
|
|
742
|
+
|
|
743
|
+
while (currentId !== null && chain.length < maxMessages) {
|
|
744
|
+
if (currentId <= boundary) break;
|
|
745
|
+
const row = this.db
|
|
746
|
+
.query(
|
|
747
|
+
`SELECT id, space_id as spaceId, role, content, attachments, run_meta as runMeta,
|
|
748
|
+
reply_to_id as replyToId, created_at as createdAt, updated_at as updatedAt
|
|
749
|
+
FROM messages WHERE id = ?`,
|
|
750
|
+
)
|
|
751
|
+
.get(currentId) as MessageRow | null;
|
|
752
|
+
if (!row) break;
|
|
753
|
+
chain.push(this.parseMessageRow(row));
|
|
754
|
+
currentId = row.replyToId;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Reverse to chronological order (oldest first)
|
|
758
|
+
return chain.reverse();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
getAnchoredContext(
|
|
762
|
+
spaceId: string,
|
|
763
|
+
anchorMessageId: number,
|
|
764
|
+
replyChainDepth: number,
|
|
765
|
+
recentTurnCount: number,
|
|
766
|
+
): { anchor: StoredMessage[]; recent: StoredMessage[] } {
|
|
767
|
+
const anchor = this.getReplyChain(
|
|
768
|
+
anchorMessageId,
|
|
769
|
+
replyChainDepth,
|
|
770
|
+
spaceId,
|
|
771
|
+
);
|
|
772
|
+
const anchorIds = new Set(anchor.map((m) => m.id));
|
|
773
|
+
const recent = this.getRecentTurns(spaceId, recentTurnCount).filter(
|
|
774
|
+
(m) => !anchorIds.has(m.id),
|
|
775
|
+
);
|
|
776
|
+
return { anchor, recent };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
updateMessageRunMeta(messageId: number, meta: MessageRunMeta): void {
|
|
780
|
+
const now = Date.now();
|
|
781
|
+
this.db
|
|
782
|
+
.query(`UPDATE messages SET run_meta = ?, updated_at = ? WHERE id = ?`)
|
|
783
|
+
.run(JSON.stringify(meta), now, messageId);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
clearMessages(spaceId: string): void {
|
|
787
|
+
this.db.query("DELETE FROM messages WHERE space_id = ?").run(spaceId);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private getSessionBoundary(spaceId: string): number {
|
|
791
|
+
const row = this.db
|
|
792
|
+
.query(
|
|
793
|
+
`SELECT max(min_message_id, clear_boundary) as minMessageId
|
|
794
|
+
FROM chat_state
|
|
795
|
+
WHERE space_id = ?`,
|
|
796
|
+
)
|
|
797
|
+
.get(spaceId) as { minMessageId: number } | null;
|
|
798
|
+
return row?.minMessageId ?? 0;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
setSessionBoundaryToLatest(spaceId: string): number {
|
|
802
|
+
const row = this.db
|
|
803
|
+
.query(
|
|
804
|
+
`SELECT COALESCE(MAX(id), 0) as id
|
|
805
|
+
FROM messages
|
|
806
|
+
WHERE space_id = ?`,
|
|
807
|
+
)
|
|
808
|
+
.get(spaceId) as { id: number } | null;
|
|
809
|
+
const minMessageId = Number(row?.id ?? 0);
|
|
810
|
+
|
|
811
|
+
const now = Date.now();
|
|
812
|
+
this.db
|
|
813
|
+
.query(
|
|
814
|
+
`INSERT INTO chat_state(space_id, min_message_id, created_at, updated_at)
|
|
815
|
+
VALUES (?, ?, ?, ?)
|
|
816
|
+
ON CONFLICT(space_id)
|
|
817
|
+
DO UPDATE SET min_message_id = excluded.min_message_id, updated_at = excluded.updated_at`,
|
|
818
|
+
)
|
|
819
|
+
.run(spaceId, minMessageId, now, now);
|
|
820
|
+
|
|
821
|
+
return minMessageId;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
setClearBoundary(spaceId: string): number {
|
|
825
|
+
const row = this.db
|
|
826
|
+
.query(
|
|
827
|
+
`SELECT COALESCE(MAX(id), 0) as id
|
|
828
|
+
FROM messages
|
|
829
|
+
WHERE space_id = ?`,
|
|
830
|
+
)
|
|
831
|
+
.get(spaceId) as { id: number } | null;
|
|
832
|
+
const clearBoundary = Number(row?.id ?? 0);
|
|
833
|
+
|
|
834
|
+
const now = Date.now();
|
|
835
|
+
this.db
|
|
836
|
+
.query(
|
|
837
|
+
`INSERT INTO chat_state(space_id, min_message_id, clear_boundary, created_at, updated_at)
|
|
838
|
+
VALUES (?, 0, ?, ?, ?)
|
|
839
|
+
ON CONFLICT(space_id)
|
|
840
|
+
DO UPDATE SET clear_boundary = excluded.clear_boundary, updated_at = excluded.updated_at`,
|
|
841
|
+
)
|
|
842
|
+
.run(spaceId, clearBoundary, now, now);
|
|
843
|
+
|
|
844
|
+
return clearBoundary;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
resetClearBoundary(spaceId: string): void {
|
|
848
|
+
this.db
|
|
849
|
+
.query(
|
|
850
|
+
`UPDATE chat_state SET clear_boundary = 0, updated_at = ?
|
|
851
|
+
WHERE space_id = ? AND clear_boundary != 0`,
|
|
852
|
+
)
|
|
853
|
+
.run(Date.now(), spaceId);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
getRecentMessages(spaceId: string, limit = 40): StoredMessage[] {
|
|
857
|
+
const boundary = this.getSessionBoundary(spaceId);
|
|
858
|
+
const rows = this.db
|
|
859
|
+
.query(
|
|
860
|
+
`SELECT
|
|
861
|
+
id,
|
|
862
|
+
space_id as spaceId,
|
|
863
|
+
role,
|
|
864
|
+
content,
|
|
865
|
+
attachments,
|
|
866
|
+
run_meta as runMeta,
|
|
867
|
+
reply_to_id as replyToId,
|
|
868
|
+
created_at as createdAt,
|
|
869
|
+
updated_at as updatedAt
|
|
870
|
+
FROM messages
|
|
871
|
+
WHERE space_id = ? AND id > ?
|
|
872
|
+
ORDER BY id DESC
|
|
873
|
+
LIMIT ?`,
|
|
874
|
+
)
|
|
875
|
+
.all(spaceId, boundary, limit) as MessageRow[];
|
|
876
|
+
return rows.map((row) => this.parseMessageRow(row));
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
getMessagesSinceLastUserTrigger(
|
|
880
|
+
spaceId: string,
|
|
881
|
+
limit = 200,
|
|
882
|
+
): StoredMessage[] {
|
|
883
|
+
const boundary = this.getSessionBoundary(spaceId);
|
|
884
|
+
|
|
885
|
+
const latestUser = this.db
|
|
886
|
+
.query(
|
|
887
|
+
`SELECT id
|
|
888
|
+
FROM messages
|
|
889
|
+
WHERE space_id = ? AND role = 'user' AND id > ?
|
|
890
|
+
ORDER BY id DESC
|
|
891
|
+
LIMIT 1`,
|
|
892
|
+
)
|
|
893
|
+
.get(spaceId, boundary) as { id: number } | null;
|
|
894
|
+
|
|
895
|
+
if (!latestUser) return [];
|
|
896
|
+
|
|
897
|
+
const previousUser = this.db
|
|
898
|
+
.query(
|
|
899
|
+
`SELECT id
|
|
900
|
+
FROM messages
|
|
901
|
+
WHERE space_id = ? AND role = 'user' AND id > ? AND id < ?
|
|
902
|
+
ORDER BY id DESC
|
|
903
|
+
LIMIT 1`,
|
|
904
|
+
)
|
|
905
|
+
.get(spaceId, boundary, latestUser.id) as { id: number } | null;
|
|
906
|
+
|
|
907
|
+
const afterId = previousUser?.id ?? boundary;
|
|
908
|
+
|
|
909
|
+
const rows = this.db
|
|
910
|
+
.query(
|
|
911
|
+
`SELECT
|
|
912
|
+
id,
|
|
913
|
+
space_id as spaceId,
|
|
914
|
+
role,
|
|
915
|
+
content,
|
|
916
|
+
attachments,
|
|
917
|
+
run_meta as runMeta,
|
|
918
|
+
reply_to_id as replyToId,
|
|
919
|
+
created_at as createdAt,
|
|
920
|
+
updated_at as updatedAt
|
|
921
|
+
FROM messages
|
|
922
|
+
WHERE space_id = ? AND id > ?
|
|
923
|
+
ORDER BY id ASC
|
|
924
|
+
LIMIT ?`,
|
|
925
|
+
)
|
|
926
|
+
.all(spaceId, afterId, limit) as MessageRow[];
|
|
927
|
+
return rows.map((row) => this.parseMessageRow(row));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Sliding window: return the last `turnCount` user→assistant turn pairs
|
|
932
|
+
* plus any ambient messages within that window. Respects session boundary
|
|
933
|
+
* so `compact` still trims history.
|
|
934
|
+
*/
|
|
935
|
+
getRecentTurns(spaceId: string, turnCount = 10): StoredMessage[] {
|
|
936
|
+
const boundary = this.getSessionBoundary(spaceId);
|
|
937
|
+
|
|
938
|
+
// Fetch a generous number of recent messages (turns * 5 for ambient padding)
|
|
939
|
+
const rows = this.db
|
|
940
|
+
.query(
|
|
941
|
+
`SELECT
|
|
942
|
+
id,
|
|
943
|
+
space_id as spaceId,
|
|
944
|
+
role,
|
|
945
|
+
content,
|
|
946
|
+
attachments,
|
|
947
|
+
run_meta as runMeta,
|
|
948
|
+
reply_to_id as replyToId,
|
|
949
|
+
created_at as createdAt,
|
|
950
|
+
updated_at as updatedAt
|
|
951
|
+
FROM messages
|
|
952
|
+
WHERE space_id = ? AND id > ?
|
|
953
|
+
ORDER BY id DESC
|
|
954
|
+
LIMIT ?`,
|
|
955
|
+
)
|
|
956
|
+
.all(spaceId, boundary, turnCount * 5) as MessageRow[];
|
|
957
|
+
|
|
958
|
+
if (rows.length === 0) return [];
|
|
959
|
+
|
|
960
|
+
// Walk backward to find N complete user+assistant turns
|
|
961
|
+
let turnsFound = 0;
|
|
962
|
+
let cutoffIndex = rows.length; // index in descending array where we stop
|
|
963
|
+
|
|
964
|
+
for (let i = 0; i < rows.length; i++) {
|
|
965
|
+
if (rows[i].role === "user") {
|
|
966
|
+
turnsFound++;
|
|
967
|
+
if (turnsFound >= turnCount) {
|
|
968
|
+
cutoffIndex = i + 1; // include this user message
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Slice to the window and reverse to ascending order
|
|
975
|
+
return rows
|
|
976
|
+
.slice(0, cutoffIndex)
|
|
977
|
+
.reverse()
|
|
978
|
+
.map((row) => this.parseMessageRow(row));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Case-insensitive substring search over stored message content for a space.
|
|
983
|
+
*/
|
|
984
|
+
searchMessages(spaceId: string, query: string, limit = 20): StoredMessage[] {
|
|
985
|
+
const q = query.trim();
|
|
986
|
+
if (!q) return [];
|
|
987
|
+
const cap = Math.min(Math.max(1, limit), 100);
|
|
988
|
+
const rows = this.db
|
|
989
|
+
.query(
|
|
990
|
+
`SELECT
|
|
991
|
+
id,
|
|
992
|
+
space_id as spaceId,
|
|
993
|
+
role,
|
|
994
|
+
content,
|
|
995
|
+
attachments,
|
|
996
|
+
run_meta as runMeta,
|
|
997
|
+
reply_to_id as replyToId,
|
|
998
|
+
created_at as createdAt,
|
|
999
|
+
updated_at as updatedAt
|
|
1000
|
+
FROM messages
|
|
1001
|
+
WHERE space_id = ? AND instr(lower(content), lower(?)) > 0
|
|
1002
|
+
ORDER BY id DESC
|
|
1003
|
+
LIMIT ?`,
|
|
1004
|
+
)
|
|
1005
|
+
.all(spaceId, q, cap) as MessageRow[];
|
|
1006
|
+
return rows.map((row) => this.parseMessageRow(row));
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
createTask(
|
|
1010
|
+
spaceId: string,
|
|
1011
|
+
schedule: { cron: string } | { at: string },
|
|
1012
|
+
prompt: string,
|
|
1013
|
+
nextRunAt: number,
|
|
1014
|
+
createdBy: string,
|
|
1015
|
+
silent = false,
|
|
1016
|
+
timezone?: string,
|
|
1017
|
+
name?: string,
|
|
1018
|
+
): number {
|
|
1019
|
+
const now = Date.now();
|
|
1020
|
+
const cron = "cron" in schedule ? schedule.cron : null;
|
|
1021
|
+
const at = "at" in schedule ? schedule.at : null;
|
|
1022
|
+
this.db
|
|
1023
|
+
.query(
|
|
1024
|
+
`INSERT INTO tasks(space_id, cron, at, prompt, active, silent, next_run_at, created_by, timezone, name, created_at, updated_at)
|
|
1025
|
+
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1026
|
+
)
|
|
1027
|
+
.run(
|
|
1028
|
+
spaceId,
|
|
1029
|
+
cron,
|
|
1030
|
+
at,
|
|
1031
|
+
prompt,
|
|
1032
|
+
silent ? 1 : 0,
|
|
1033
|
+
nextRunAt,
|
|
1034
|
+
createdBy,
|
|
1035
|
+
timezone ?? null,
|
|
1036
|
+
name ?? null,
|
|
1037
|
+
now,
|
|
1038
|
+
now,
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
const row = this.db.query("SELECT last_insert_rowid() as id").get() as {
|
|
1042
|
+
id: number;
|
|
1043
|
+
} | null;
|
|
1044
|
+
if (!row) throw new Error("Failed to read task id");
|
|
1045
|
+
return Number(row.id);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
listTasks(spaceId?: string): ScheduledTask[] {
|
|
1049
|
+
if (spaceId) {
|
|
1050
|
+
return this.db
|
|
1051
|
+
.query(
|
|
1052
|
+
`SELECT
|
|
1053
|
+
id,
|
|
1054
|
+
space_id as spaceId,
|
|
1055
|
+
cron,
|
|
1056
|
+
at,
|
|
1057
|
+
prompt,
|
|
1058
|
+
active,
|
|
1059
|
+
silent,
|
|
1060
|
+
next_run_at as nextRunAt,
|
|
1061
|
+
created_by as createdBy,
|
|
1062
|
+
timezone,
|
|
1063
|
+
name,
|
|
1064
|
+
created_at as createdAt,
|
|
1065
|
+
updated_at as updatedAt
|
|
1066
|
+
FROM tasks
|
|
1067
|
+
WHERE space_id = ?
|
|
1068
|
+
ORDER BY id ASC`,
|
|
1069
|
+
)
|
|
1070
|
+
.all(spaceId) as ScheduledTask[];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return this.db
|
|
1074
|
+
.query(
|
|
1075
|
+
`SELECT
|
|
1076
|
+
id,
|
|
1077
|
+
space_id as spaceId,
|
|
1078
|
+
cron,
|
|
1079
|
+
at,
|
|
1080
|
+
prompt,
|
|
1081
|
+
active,
|
|
1082
|
+
silent,
|
|
1083
|
+
next_run_at as nextRunAt,
|
|
1084
|
+
created_by as createdBy,
|
|
1085
|
+
timezone,
|
|
1086
|
+
name,
|
|
1087
|
+
created_at as createdAt,
|
|
1088
|
+
updated_at as updatedAt
|
|
1089
|
+
FROM tasks
|
|
1090
|
+
ORDER BY id ASC`,
|
|
1091
|
+
)
|
|
1092
|
+
.all() as ScheduledTask[];
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
getDueTasks(now = Date.now()): ScheduledTask[] {
|
|
1096
|
+
return this.db
|
|
1097
|
+
.query(
|
|
1098
|
+
`SELECT
|
|
1099
|
+
id,
|
|
1100
|
+
space_id as spaceId,
|
|
1101
|
+
cron,
|
|
1102
|
+
at,
|
|
1103
|
+
prompt,
|
|
1104
|
+
active,
|
|
1105
|
+
silent,
|
|
1106
|
+
next_run_at as nextRunAt,
|
|
1107
|
+
created_by as createdBy,
|
|
1108
|
+
timezone,
|
|
1109
|
+
name,
|
|
1110
|
+
created_at as createdAt,
|
|
1111
|
+
updated_at as updatedAt
|
|
1112
|
+
FROM tasks
|
|
1113
|
+
WHERE active = 1 AND next_run_at <= ?
|
|
1114
|
+
ORDER BY next_run_at ASC`,
|
|
1115
|
+
)
|
|
1116
|
+
.all(now) as ScheduledTask[];
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
updateTaskNextRun(id: number, nextRunAt: number): void {
|
|
1120
|
+
this.db
|
|
1121
|
+
.query("UPDATE tasks SET next_run_at = ?, updated_at = ? WHERE id = ?")
|
|
1122
|
+
.run(nextRunAt, Date.now(), id);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
setTaskActive(id: number, active: boolean): void {
|
|
1126
|
+
this.db
|
|
1127
|
+
.query("UPDATE tasks SET active = ?, updated_at = ? WHERE id = ?")
|
|
1128
|
+
.run(active ? 1 : 0, Date.now(), id);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
deleteTask(id: number, spaceId: string): boolean {
|
|
1132
|
+
const result = this.db
|
|
1133
|
+
.query("DELETE FROM tasks WHERE id = ? AND space_id = ?")
|
|
1134
|
+
.run(id, spaceId);
|
|
1135
|
+
return result.changes > 0;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
deleteTaskById(id: number): boolean {
|
|
1139
|
+
const result = this.db.query("DELETE FROM tasks WHERE id = ?").run(id);
|
|
1140
|
+
return result.changes > 0;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
getTask(id: number): ScheduledTask | null {
|
|
1144
|
+
return this.db
|
|
1145
|
+
.query(
|
|
1146
|
+
`SELECT
|
|
1147
|
+
id,
|
|
1148
|
+
space_id as spaceId,
|
|
1149
|
+
cron,
|
|
1150
|
+
at,
|
|
1151
|
+
prompt,
|
|
1152
|
+
active,
|
|
1153
|
+
silent,
|
|
1154
|
+
next_run_at as nextRunAt,
|
|
1155
|
+
created_by as createdBy,
|
|
1156
|
+
timezone,
|
|
1157
|
+
name,
|
|
1158
|
+
created_at as createdAt,
|
|
1159
|
+
updated_at as updatedAt
|
|
1160
|
+
FROM tasks
|
|
1161
|
+
WHERE id = ?`,
|
|
1162
|
+
)
|
|
1163
|
+
.get(id) as ScheduledTask | null;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// --- Roles ---
|
|
1167
|
+
|
|
1168
|
+
upsertMember(
|
|
1169
|
+
spaceId: string,
|
|
1170
|
+
platformUserId: string,
|
|
1171
|
+
displayName?: string | null,
|
|
1172
|
+
): void {
|
|
1173
|
+
const now = Date.now();
|
|
1174
|
+
this.db
|
|
1175
|
+
.query(
|
|
1176
|
+
`INSERT INTO space_roles(space_id, platform_user_id, role, granted_by, display_name, created_at, updated_at)
|
|
1177
|
+
VALUES (?, ?, 'member', NULL, ?, ?, ?)
|
|
1178
|
+
ON CONFLICT(space_id, platform_user_id) DO UPDATE SET
|
|
1179
|
+
display_name = COALESCE(excluded.display_name, space_roles.display_name),
|
|
1180
|
+
updated_at = excluded.updated_at`,
|
|
1181
|
+
)
|
|
1182
|
+
.run(spaceId, platformUserId, displayName ?? null, now, now);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
setRole(
|
|
1186
|
+
spaceId: string,
|
|
1187
|
+
platformUserId: string,
|
|
1188
|
+
role: string,
|
|
1189
|
+
grantedBy: string,
|
|
1190
|
+
): void {
|
|
1191
|
+
const now = Date.now();
|
|
1192
|
+
this.db
|
|
1193
|
+
.query(
|
|
1194
|
+
`INSERT INTO space_roles(space_id, platform_user_id, role, granted_by, created_at, updated_at)
|
|
1195
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1196
|
+
ON CONFLICT(space_id, platform_user_id)
|
|
1197
|
+
DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, updated_at = excluded.updated_at`,
|
|
1198
|
+
)
|
|
1199
|
+
.run(spaceId, platformUserId, role, grantedBy, now, now);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
getRole(spaceId: string, platformUserId: string): string | null {
|
|
1203
|
+
const row = this.db
|
|
1204
|
+
.query(
|
|
1205
|
+
`SELECT role FROM space_roles
|
|
1206
|
+
WHERE space_id = ? AND platform_user_id = ?`,
|
|
1207
|
+
)
|
|
1208
|
+
.get(spaceId, platformUserId) as { role: string } | null;
|
|
1209
|
+
return row?.role ?? null;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
listRoles(spaceId: string): SpaceRole[] {
|
|
1213
|
+
return this.db
|
|
1214
|
+
.query(
|
|
1215
|
+
`SELECT
|
|
1216
|
+
space_id as spaceId,
|
|
1217
|
+
platform_user_id as platformUserId,
|
|
1218
|
+
display_name as displayName,
|
|
1219
|
+
role,
|
|
1220
|
+
granted_by as grantedBy,
|
|
1221
|
+
created_at as createdAt,
|
|
1222
|
+
updated_at as updatedAt
|
|
1223
|
+
FROM space_roles
|
|
1224
|
+
WHERE space_id = ?
|
|
1225
|
+
ORDER BY created_at ASC`,
|
|
1226
|
+
)
|
|
1227
|
+
.all(spaceId) as SpaceRole[];
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
deleteRole(spaceId: string, platformUserId: string): boolean {
|
|
1231
|
+
const result = this.db
|
|
1232
|
+
.query(
|
|
1233
|
+
`DELETE FROM space_roles
|
|
1234
|
+
WHERE space_id = ? AND platform_user_id = ?`,
|
|
1235
|
+
)
|
|
1236
|
+
.run(spaceId, platformUserId);
|
|
1237
|
+
return result.changes > 0;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
seedAdmins(spaceId: string, adminIds: string[]): void {
|
|
1241
|
+
const now = Date.now();
|
|
1242
|
+
for (const id of adminIds) {
|
|
1243
|
+
this.db
|
|
1244
|
+
.query(
|
|
1245
|
+
`INSERT INTO space_roles(space_id, platform_user_id, role, granted_by, created_at, updated_at)
|
|
1246
|
+
VALUES (?, ?, 'admin', 'seed', ?, ?)
|
|
1247
|
+
ON CONFLICT(space_id, platform_user_id)
|
|
1248
|
+
DO UPDATE SET role = 'admin', granted_by = 'seed', updated_at = excluded.updated_at
|
|
1249
|
+
WHERE space_roles.role != 'admin'`,
|
|
1250
|
+
)
|
|
1251
|
+
.run(spaceId, id, now, now);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// --- Space Config ---
|
|
1256
|
+
|
|
1257
|
+
getSpaceConfig(spaceId: string, key: string): string | null {
|
|
1258
|
+
const row = this.db
|
|
1259
|
+
.query("SELECT value FROM space_config WHERE space_id = ? AND key = ?")
|
|
1260
|
+
.get(spaceId, key) as { value: string } | null;
|
|
1261
|
+
return row?.value ?? null;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
setSpaceConfig(
|
|
1265
|
+
spaceId: string,
|
|
1266
|
+
key: string,
|
|
1267
|
+
value: string,
|
|
1268
|
+
updatedBy: string,
|
|
1269
|
+
): void {
|
|
1270
|
+
const now = Date.now();
|
|
1271
|
+
this.db
|
|
1272
|
+
.query(
|
|
1273
|
+
`INSERT INTO space_config(space_id, key, value, updated_by, created_at, updated_at)
|
|
1274
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1275
|
+
ON CONFLICT(space_id, key)
|
|
1276
|
+
DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
|
|
1277
|
+
)
|
|
1278
|
+
.run(spaceId, key, value, updatedBy, now, now);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
listSpaceConfig(spaceId: string): SpaceConfigEntry[] {
|
|
1282
|
+
return this.db
|
|
1283
|
+
.query(
|
|
1284
|
+
`SELECT
|
|
1285
|
+
space_id as spaceId,
|
|
1286
|
+
key,
|
|
1287
|
+
value,
|
|
1288
|
+
updated_by as updatedBy,
|
|
1289
|
+
created_at as createdAt,
|
|
1290
|
+
updated_at as updatedAt
|
|
1291
|
+
FROM space_config
|
|
1292
|
+
WHERE space_id = ?
|
|
1293
|
+
ORDER BY key ASC`,
|
|
1294
|
+
)
|
|
1295
|
+
.all(spaceId) as SpaceConfigEntry[];
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Remove one config row; returns whether a row was deleted. */
|
|
1299
|
+
deleteSpaceConfig(spaceId: string, key: string): boolean {
|
|
1300
|
+
const res = this.db
|
|
1301
|
+
.query("DELETE FROM space_config WHERE space_id = ? AND key = ?")
|
|
1302
|
+
.run(spaceId, key);
|
|
1303
|
+
return res.changes > 0;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// --- Space preferences (chat-managed) ---
|
|
1307
|
+
|
|
1308
|
+
getSpacePreference(spaceId: string, key: string): string | null {
|
|
1309
|
+
const row = this.db
|
|
1310
|
+
.query(
|
|
1311
|
+
"SELECT value FROM space_preferences WHERE space_id = ? AND key = ?",
|
|
1312
|
+
)
|
|
1313
|
+
.get(spaceId, key) as { value: string } | null;
|
|
1314
|
+
return row?.value ?? null;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
countSpacePreferences(spaceId: string): number {
|
|
1318
|
+
const row = this.db
|
|
1319
|
+
.query("SELECT COUNT(*) as c FROM space_preferences WHERE space_id = ?")
|
|
1320
|
+
.get(spaceId) as { c: number };
|
|
1321
|
+
return Number(row?.c ?? 0);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
setSpacePreference(
|
|
1325
|
+
spaceId: string,
|
|
1326
|
+
key: string,
|
|
1327
|
+
value: string,
|
|
1328
|
+
createdBy: string,
|
|
1329
|
+
): void {
|
|
1330
|
+
const now = Date.now();
|
|
1331
|
+
const existing = this.getSpacePreference(spaceId, key);
|
|
1332
|
+
if (existing === null && this.countSpacePreferences(spaceId) >= 50) {
|
|
1333
|
+
throw new Error("Maximum 50 preferences per space");
|
|
1334
|
+
}
|
|
1335
|
+
this.db
|
|
1336
|
+
.query(
|
|
1337
|
+
`INSERT INTO space_preferences(space_id, key, value, created_by, created_at, updated_at)
|
|
1338
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1339
|
+
ON CONFLICT(space_id, key)
|
|
1340
|
+
DO UPDATE SET value = excluded.value, created_by = excluded.created_by, updated_at = excluded.updated_at`,
|
|
1341
|
+
)
|
|
1342
|
+
.run(spaceId, key, value, createdBy, now, now);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
deleteSpacePreference(spaceId: string, key: string): boolean {
|
|
1346
|
+
const result = this.db
|
|
1347
|
+
.query("DELETE FROM space_preferences WHERE space_id = ? AND key = ?")
|
|
1348
|
+
.run(spaceId, key);
|
|
1349
|
+
return result.changes > 0;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
listSpacePreferences(spaceId: string): SpacePreferenceEntry[] {
|
|
1353
|
+
return this.db
|
|
1354
|
+
.query(
|
|
1355
|
+
`SELECT
|
|
1356
|
+
space_id as spaceId,
|
|
1357
|
+
key,
|
|
1358
|
+
value,
|
|
1359
|
+
created_by as createdBy,
|
|
1360
|
+
created_at as createdAt,
|
|
1361
|
+
updated_at as updatedAt
|
|
1362
|
+
FROM space_preferences
|
|
1363
|
+
WHERE space_id = ?
|
|
1364
|
+
ORDER BY key ASC`,
|
|
1365
|
+
)
|
|
1366
|
+
.all(spaceId) as SpacePreferenceEntry[];
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// --- Extension State ---
|
|
1370
|
+
|
|
1371
|
+
getExtState(extension: string, key: string): string | null {
|
|
1372
|
+
const row = this.db
|
|
1373
|
+
.query(
|
|
1374
|
+
"SELECT value FROM extension_state WHERE extension = ? AND key = ?",
|
|
1375
|
+
)
|
|
1376
|
+
.get(extension, key) as { value: string } | null;
|
|
1377
|
+
return row?.value ?? null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** True if the extension has at least one stored state entry (proxy for "ever connected"). */
|
|
1381
|
+
hasAnyExtensionState(extensionName: string): boolean {
|
|
1382
|
+
const row = this.db
|
|
1383
|
+
.query("SELECT 1 FROM extension_state WHERE extension = ? LIMIT 1")
|
|
1384
|
+
.get(extensionName);
|
|
1385
|
+
return row !== null;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
setExtState(extension: string, key: string, value: string): void {
|
|
1389
|
+
const now = Date.now();
|
|
1390
|
+
this.db
|
|
1391
|
+
.query(
|
|
1392
|
+
`INSERT INTO extension_state(extension, key, value, created_at, updated_at)
|
|
1393
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1394
|
+
ON CONFLICT(extension, key)
|
|
1395
|
+
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
|
1396
|
+
)
|
|
1397
|
+
.run(extension, key, value, now, now);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
deleteExtState(extension: string, key: string): boolean {
|
|
1401
|
+
const result = this.db
|
|
1402
|
+
.query("DELETE FROM extension_state WHERE extension = ? AND key = ?")
|
|
1403
|
+
.run(extension, key);
|
|
1404
|
+
return result.changes > 0;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
listExtState(extension: string): Array<{ key: string; value: string }> {
|
|
1408
|
+
return this.db
|
|
1409
|
+
.query(
|
|
1410
|
+
"SELECT key, value FROM extension_state WHERE extension = ? ORDER BY key ASC",
|
|
1411
|
+
)
|
|
1412
|
+
.all(extension) as Array<{ key: string; value: string }>;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// ─── Mutes ─────────────────────────────────────────────────────────────
|
|
1416
|
+
|
|
1417
|
+
muteUser(
|
|
1418
|
+
spaceId: string,
|
|
1419
|
+
platformUserId: string,
|
|
1420
|
+
expiresAt: number,
|
|
1421
|
+
mutedBy: string,
|
|
1422
|
+
reason?: string,
|
|
1423
|
+
): void {
|
|
1424
|
+
const now = Date.now();
|
|
1425
|
+
this.db
|
|
1426
|
+
.query(
|
|
1427
|
+
`INSERT INTO mutes (space_id, platform_user_id, expires_at, reason, muted_by, created_at)
|
|
1428
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1429
|
+
ON CONFLICT(space_id, platform_user_id) DO UPDATE SET
|
|
1430
|
+
expires_at = excluded.expires_at,
|
|
1431
|
+
reason = excluded.reason,
|
|
1432
|
+
muted_by = excluded.muted_by`,
|
|
1433
|
+
)
|
|
1434
|
+
.run(spaceId, platformUserId, expiresAt, reason ?? null, mutedBy, now);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
unmuteUser(spaceId: string, platformUserId: string): boolean {
|
|
1438
|
+
const result = this.db
|
|
1439
|
+
.query("DELETE FROM mutes WHERE space_id = ? AND platform_user_id = ?")
|
|
1440
|
+
.run(spaceId, platformUserId);
|
|
1441
|
+
return result.changes > 0;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
isMuted(spaceId: string, platformUserId: string): boolean {
|
|
1445
|
+
const now = Date.now();
|
|
1446
|
+
// Clean up expired mute and return false
|
|
1447
|
+
const row = this.db
|
|
1448
|
+
.query(
|
|
1449
|
+
"SELECT expires_at FROM mutes WHERE space_id = ? AND platform_user_id = ?",
|
|
1450
|
+
)
|
|
1451
|
+
.get(spaceId, platformUserId) as { expires_at: number } | null;
|
|
1452
|
+
|
|
1453
|
+
if (!row) return false;
|
|
1454
|
+
if (row.expires_at <= now) {
|
|
1455
|
+
this.unmuteUser(spaceId, platformUserId);
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
1458
|
+
return true;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
getMute(
|
|
1462
|
+
spaceId: string,
|
|
1463
|
+
platformUserId: string,
|
|
1464
|
+
): {
|
|
1465
|
+
platformUserId: string;
|
|
1466
|
+
expiresAt: number;
|
|
1467
|
+
reason: string | null;
|
|
1468
|
+
mutedBy: string;
|
|
1469
|
+
} | null {
|
|
1470
|
+
const now = Date.now();
|
|
1471
|
+
const row = this.db
|
|
1472
|
+
.query(
|
|
1473
|
+
`SELECT platform_user_id, expires_at, reason, muted_by
|
|
1474
|
+
FROM mutes WHERE space_id = ? AND platform_user_id = ? AND expires_at > ?`,
|
|
1475
|
+
)
|
|
1476
|
+
.get(spaceId, platformUserId, now) as {
|
|
1477
|
+
platform_user_id: string;
|
|
1478
|
+
expires_at: number;
|
|
1479
|
+
reason: string | null;
|
|
1480
|
+
muted_by: string;
|
|
1481
|
+
} | null;
|
|
1482
|
+
|
|
1483
|
+
if (!row) return null;
|
|
1484
|
+
return {
|
|
1485
|
+
platformUserId: row.platform_user_id,
|
|
1486
|
+
expiresAt: row.expires_at,
|
|
1487
|
+
reason: row.reason,
|
|
1488
|
+
mutedBy: row.muted_by,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
listMutes(spaceId: string): Array<{
|
|
1493
|
+
platformUserId: string;
|
|
1494
|
+
expiresAt: number;
|
|
1495
|
+
reason: string | null;
|
|
1496
|
+
mutedBy: string;
|
|
1497
|
+
}> {
|
|
1498
|
+
const now = Date.now();
|
|
1499
|
+
// Clean expired
|
|
1500
|
+
this.db
|
|
1501
|
+
.query("DELETE FROM mutes WHERE space_id = ? AND expires_at <= ?")
|
|
1502
|
+
.run(spaceId, now);
|
|
1503
|
+
|
|
1504
|
+
return (
|
|
1505
|
+
this.db
|
|
1506
|
+
.query(
|
|
1507
|
+
`SELECT platform_user_id, expires_at, reason, muted_by
|
|
1508
|
+
FROM mutes WHERE space_id = ? ORDER BY expires_at ASC`,
|
|
1509
|
+
)
|
|
1510
|
+
.all(spaceId) as Array<{
|
|
1511
|
+
platform_user_id: string;
|
|
1512
|
+
expires_at: number;
|
|
1513
|
+
reason: string | null;
|
|
1514
|
+
muted_by: string;
|
|
1515
|
+
}>
|
|
1516
|
+
).map((r) => ({
|
|
1517
|
+
platformUserId: r.platform_user_id,
|
|
1518
|
+
expiresAt: r.expires_at,
|
|
1519
|
+
reason: r.reason,
|
|
1520
|
+
mutedBy: r.muted_by,
|
|
1521
|
+
}));
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// ─── Token Usage ──────────────────────────────────────────────────────
|
|
1525
|
+
|
|
1526
|
+
recordUsage(spaceId: string, usage: TokenUsage): void {
|
|
1527
|
+
const now = Date.now();
|
|
1528
|
+
this.db
|
|
1529
|
+
.query(
|
|
1530
|
+
`INSERT INTO token_usage(space_id, input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens, cost, model, provider, created_at)
|
|
1531
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1532
|
+
)
|
|
1533
|
+
.run(
|
|
1534
|
+
spaceId,
|
|
1535
|
+
usage.inputTokens ?? null,
|
|
1536
|
+
usage.outputTokens ?? null,
|
|
1537
|
+
usage.totalTokens ?? null,
|
|
1538
|
+
usage.cacheReadTokens ?? null,
|
|
1539
|
+
usage.cacheWriteTokens ?? null,
|
|
1540
|
+
usage.cost ?? null,
|
|
1541
|
+
usage.model ?? null,
|
|
1542
|
+
usage.provider ?? null,
|
|
1543
|
+
now,
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
getUsageSummary(): Array<{
|
|
1548
|
+
spaceId: string;
|
|
1549
|
+
spaceName: string;
|
|
1550
|
+
totalInputTokens: number;
|
|
1551
|
+
totalOutputTokens: number;
|
|
1552
|
+
totalTokens: number;
|
|
1553
|
+
totalCost: number;
|
|
1554
|
+
runCount: number;
|
|
1555
|
+
lastUsedAt: number;
|
|
1556
|
+
}> {
|
|
1557
|
+
return this.db
|
|
1558
|
+
.query(
|
|
1559
|
+
`SELECT
|
|
1560
|
+
t.space_id as spaceId,
|
|
1561
|
+
COALESCE(s.name, t.space_id) as spaceName,
|
|
1562
|
+
COALESCE(SUM(t.input_tokens), 0) as totalInputTokens,
|
|
1563
|
+
COALESCE(SUM(t.output_tokens), 0) as totalOutputTokens,
|
|
1564
|
+
COALESCE(SUM(t.total_tokens), 0) as totalTokens,
|
|
1565
|
+
COALESCE(SUM(t.cost), 0) as totalCost,
|
|
1566
|
+
COUNT(*) as runCount,
|
|
1567
|
+
MAX(t.created_at) as lastUsedAt
|
|
1568
|
+
FROM token_usage t
|
|
1569
|
+
LEFT JOIN spaces s ON s.id = t.space_id
|
|
1570
|
+
GROUP BY t.space_id
|
|
1571
|
+
ORDER BY lastUsedAt DESC`,
|
|
1572
|
+
)
|
|
1573
|
+
.all() as Array<{
|
|
1574
|
+
spaceId: string;
|
|
1575
|
+
spaceName: string;
|
|
1576
|
+
totalInputTokens: number;
|
|
1577
|
+
totalOutputTokens: number;
|
|
1578
|
+
totalTokens: number;
|
|
1579
|
+
totalCost: number;
|
|
1580
|
+
runCount: number;
|
|
1581
|
+
lastUsedAt: number;
|
|
1582
|
+
}>;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
getUsageTotals(): {
|
|
1586
|
+
totalInputTokens: number;
|
|
1587
|
+
totalOutputTokens: number;
|
|
1588
|
+
totalTokens: number;
|
|
1589
|
+
totalCost: number;
|
|
1590
|
+
runCount: number;
|
|
1591
|
+
} {
|
|
1592
|
+
const row = this.db
|
|
1593
|
+
.query(
|
|
1594
|
+
`SELECT
|
|
1595
|
+
COALESCE(SUM(input_tokens), 0) as totalInputTokens,
|
|
1596
|
+
COALESCE(SUM(output_tokens), 0) as totalOutputTokens,
|
|
1597
|
+
COALESCE(SUM(total_tokens), 0) as totalTokens,
|
|
1598
|
+
COALESCE(SUM(cost), 0) as totalCost,
|
|
1599
|
+
COUNT(*) as runCount
|
|
1600
|
+
FROM token_usage`,
|
|
1601
|
+
)
|
|
1602
|
+
.get() as {
|
|
1603
|
+
totalInputTokens: number;
|
|
1604
|
+
totalOutputTokens: number;
|
|
1605
|
+
totalTokens: number;
|
|
1606
|
+
totalCost: number;
|
|
1607
|
+
runCount: number;
|
|
1608
|
+
} | null;
|
|
1609
|
+
|
|
1610
|
+
return (
|
|
1611
|
+
row ?? {
|
|
1612
|
+
totalInputTokens: 0,
|
|
1613
|
+
totalOutputTokens: 0,
|
|
1614
|
+
totalTokens: 0,
|
|
1615
|
+
totalCost: 0,
|
|
1616
|
+
runCount: 0,
|
|
1617
|
+
}
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
close(): void {
|
|
1622
|
+
this.db.close();
|
|
1623
|
+
}
|
|
1624
|
+
}
|