n2n-nexus 0.4.2

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.
@@ -0,0 +1,75 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { NexusPaths } from "./paths.js";
4
+ import { FILE_ENCODING } from "../constants.js";
5
+ export class ProjectStorage {
6
+ static projectFilePath(projectId) {
7
+ return {
8
+ docsPath: NexusPaths.projectDocPath(projectId),
9
+ assetsDir: NexusPaths.projectAssetsDir(projectId),
10
+ manifestPath: NexusPaths.projectManifestPath(projectId),
11
+ projectDir: NexusPaths.projectDir(projectId)
12
+ };
13
+ }
14
+ static async getProjectDocs(projectId) {
15
+ const paths = this.projectFilePath(projectId);
16
+ try {
17
+ return await fs.readFile(paths.docsPath, FILE_ENCODING);
18
+ }
19
+ catch {
20
+ return "";
21
+ }
22
+ }
23
+ static async saveProjectDocs(projectId, content) {
24
+ const paths = this.projectFilePath(projectId);
25
+ await fs.mkdir(paths.projectDir, { recursive: true });
26
+ await fs.writeFile(paths.docsPath, content, FILE_ENCODING);
27
+ }
28
+ static async saveAsset(projectId, fileName, content) {
29
+ const safeName = this.sanitizeFileName(fileName);
30
+ const paths = this.projectFilePath(projectId);
31
+ await fs.mkdir(paths.assetsDir, { recursive: true });
32
+ const assetPath = path.join(paths.assetsDir, safeName);
33
+ await fs.writeFile(assetPath, content, FILE_ENCODING);
34
+ return path.relative(NexusPaths.root, assetPath);
35
+ }
36
+ static async deleteProject(projectId) {
37
+ const paths = this.projectFilePath(projectId);
38
+ try {
39
+ await fs.rm(paths.projectDir, { recursive: true, force: true });
40
+ return 1;
41
+ }
42
+ catch {
43
+ return 0;
44
+ }
45
+ }
46
+ static async renameProject(oldId, newId) {
47
+ if (!oldId || !newId || oldId === newId)
48
+ return 0;
49
+ const oldPath = this.projectFilePath(oldId);
50
+ const newPath = this.projectFilePath(newId);
51
+ try {
52
+ await fs.access(oldPath.projectDir);
53
+ }
54
+ catch {
55
+ return 0;
56
+ }
57
+ await fs.mkdir(path.dirname(newPath.projectDir), { recursive: true });
58
+ await fs.rename(oldPath.projectDir, newPath.projectDir);
59
+ try {
60
+ const manifestRaw = await fs.readFile(newPath.manifestPath, FILE_ENCODING);
61
+ const manifest = JSON.parse(manifestRaw);
62
+ manifest.id = newId;
63
+ await fs.writeFile(newPath.manifestPath, JSON.stringify(manifest, null, 2), FILE_ENCODING);
64
+ }
65
+ catch {
66
+ // Manifest is optional for some flows.
67
+ }
68
+ return 1;
69
+ }
70
+ static sanitizeFileName(fileName) {
71
+ return fileName
72
+ .replace(/[\\/:*?"<>|]/g, "_")
73
+ .replace(/\.\./g, "_");
74
+ }
75
+ }
@@ -0,0 +1,230 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { NexusPaths } from "./paths.js";
3
+ const emptyRegistry = { projects: {} };
4
+ async function readRegistry() {
5
+ try {
6
+ const raw = await fs.readFile(NexusPaths.registryFile, "utf-8");
7
+ const parsed = JSON.parse(raw);
8
+ return sanitizeRegistry(parsed);
9
+ }
10
+ catch {
11
+ return { ...emptyRegistry };
12
+ }
13
+ }
14
+ function sanitizeRegistry(data) {
15
+ if (!data || typeof data !== "object")
16
+ return { ...emptyRegistry };
17
+ const root = data;
18
+ if (!root.projects || typeof root.projects !== "object")
19
+ return { ...emptyRegistry };
20
+ return {
21
+ projects: Object.fromEntries(Object.entries(root.projects).map(([id, item]) => {
22
+ if (!item || typeof item !== "object") {
23
+ return [id, {
24
+ summary: "",
25
+ lastActive: new Date().toISOString()
26
+ }];
27
+ }
28
+ const entry = item;
29
+ return [id, {
30
+ name: typeof entry.name === "string" ? entry.name : undefined,
31
+ summary: typeof entry.summary === "string" ? entry.summary : "",
32
+ lastActive: typeof entry.lastActive === "string" ? entry.lastActive : new Date().toISOString()
33
+ }];
34
+ }))
35
+ };
36
+ }
37
+ async function writeRegistry(data) {
38
+ await fs.mkdir(NexusPaths.root, { recursive: true });
39
+ await fs.writeFile(NexusPaths.registryFile, JSON.stringify(data, null, 2), "utf-8");
40
+ }
41
+ async function readProjectManifest(id) {
42
+ try {
43
+ const raw = await fs.readFile(NexusPaths.projectManifestPath(id), "utf-8");
44
+ return JSON.parse(raw);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ export class RegistryStorage {
51
+ static async listRegistry() {
52
+ return readRegistry();
53
+ }
54
+ static async saveProjectManifest(manifest) {
55
+ const projectDir = NexusPaths.projectDir(manifest.id);
56
+ const manifestPath = NexusPaths.projectManifestPath(manifest.id);
57
+ const registry = await readRegistry();
58
+ registry.projects[manifest.id] = {
59
+ name: manifest.name,
60
+ summary: manifest.description || "",
61
+ lastActive: new Date().toISOString()
62
+ };
63
+ await writeRegistry(registry);
64
+ await fs.mkdir(projectDir, { recursive: true });
65
+ await fs.writeFile(manifestPath, JSON.stringify({ ...manifest, lastUpdated: manifest.lastUpdated || new Date().toISOString() }, null, 2), "utf-8");
66
+ }
67
+ static async getProjectManifest(id) {
68
+ const registry = await readRegistry();
69
+ const entry = registry.projects[id];
70
+ if (!entry)
71
+ return null;
72
+ const rawManifest = await readProjectManifest(id);
73
+ return {
74
+ id,
75
+ name: entry.name || rawManifest?.name || id,
76
+ description: entry.summary || rawManifest?.description || "",
77
+ techStack: rawManifest?.techStack || [],
78
+ relations: rawManifest?.relations || [],
79
+ lastUpdated: rawManifest?.lastUpdated || entry.lastActive,
80
+ repositoryUrl: rawManifest?.repositoryUrl || "",
81
+ localPath: rawManifest?.localPath || NexusPaths.projectDir(id),
82
+ endpoints: rawManifest?.endpoints || [],
83
+ apiSpec: rawManifest?.apiSpec || [],
84
+ apiDependencies: rawManifest?.apiDependencies,
85
+ gatewayCompatibility: rawManifest?.gatewayCompatibility,
86
+ api_versions: rawManifest?.api_versions,
87
+ feature_tier: rawManifest?.feature_tier
88
+ };
89
+ }
90
+ static async patchProjectManifest(id, patch) {
91
+ const registry = await readRegistry();
92
+ const existing = registry.projects[id];
93
+ if (!existing)
94
+ throw new Error(`Project '${id}' not found.`);
95
+ registry.projects[id] = {
96
+ ...existing,
97
+ summary: patch.description ?? existing.summary,
98
+ name: patch.name ?? existing.name,
99
+ lastActive: new Date().toISOString()
100
+ };
101
+ await writeRegistry(registry);
102
+ const manifest = await readProjectManifest(id) || {};
103
+ const patchedManifest = {
104
+ id,
105
+ name: patch.name ?? manifest.name ?? existing.name ?? "",
106
+ description: patch.description ?? manifest.description ?? "",
107
+ techStack: patch.techStack ?? manifest.techStack ?? [],
108
+ relations: patch.relations ?? manifest.relations ?? [],
109
+ lastUpdated: new Date().toISOString(),
110
+ repositoryUrl: patch.repositoryUrl ?? manifest.repositoryUrl ?? "",
111
+ localPath: patch.localPath ?? manifest.localPath ?? NexusPaths.projectDir(id),
112
+ endpoints: patch.endpoints ?? manifest.endpoints ?? [],
113
+ apiSpec: patch.apiSpec ?? manifest.apiSpec ?? [],
114
+ apiDependencies: patch.apiDependencies ?? manifest.apiDependencies,
115
+ gatewayCompatibility: patch.gatewayCompatibility ?? manifest.gatewayCompatibility,
116
+ api_versions: patch.api_versions ?? manifest.api_versions,
117
+ feature_tier: patch.feature_tier ?? manifest.feature_tier
118
+ };
119
+ await fs.writeFile(NexusPaths.projectManifestPath(id), JSON.stringify(patchedManifest, null, 2), "utf-8");
120
+ }
121
+ static async calculateTopology(projectId) {
122
+ const registry = await readRegistry();
123
+ const projectRecords = await Promise.all(Object.entries(registry.projects).map(async ([id, project]) => {
124
+ const manifest = await readProjectManifest(id);
125
+ return {
126
+ id,
127
+ name: manifest?.name || project.name || id,
128
+ summary: project.summary,
129
+ lastActive: project.lastActive,
130
+ relations: manifest?.relations || []
131
+ };
132
+ }));
133
+ const nodeMap = new Map(projectRecords.map((record) => [record.id, record]));
134
+ const edges = projectRecords.flatMap((project) => {
135
+ return project.relations
136
+ .filter(relation => nodeMap.has(relation.targetId))
137
+ .map((relation) => ({
138
+ from: project.id,
139
+ to: relation.targetId,
140
+ type: relation.type
141
+ }));
142
+ });
143
+ const allProjects = projectRecords.map((project) => ({
144
+ id: project.id,
145
+ name: project.name,
146
+ summary: project.summary,
147
+ lastActive: project.lastActive
148
+ }));
149
+ if (!projectId) {
150
+ return {
151
+ mode: "list",
152
+ summary: {
153
+ totalProjects: allProjects.length,
154
+ totalEdges: edges.length
155
+ },
156
+ projects: Object.fromEntries(allProjects.map((project) => [project.id, project]))
157
+ };
158
+ }
159
+ if (!nodeMap.has(projectId)) {
160
+ return {
161
+ mode: "focused",
162
+ nodes: [],
163
+ edges: []
164
+ };
165
+ }
166
+ const visited = new Set();
167
+ const queue = [projectId];
168
+ while (queue.length > 0) {
169
+ const current = queue.shift();
170
+ if (!current || visited.has(current)) {
171
+ continue;
172
+ }
173
+ visited.add(current);
174
+ for (const edge of edges) {
175
+ if (edge.from === current && !visited.has(edge.to)) {
176
+ queue.push(edge.to);
177
+ }
178
+ }
179
+ }
180
+ return {
181
+ mode: "focused",
182
+ nodes: allProjects.filter((project) => visited.has(project.id)),
183
+ edges: edges.filter((edge) => visited.has(edge.from) && visited.has(edge.to))
184
+ };
185
+ }
186
+ static async deleteProject(projectId) {
187
+ const registry = await readRegistry();
188
+ if (!registry.projects[projectId])
189
+ return 0;
190
+ delete registry.projects[projectId];
191
+ await writeRegistry(registry);
192
+ return 1;
193
+ }
194
+ static async renameProject(oldId, newId) {
195
+ const registry = await readRegistry();
196
+ if (!registry.projects[oldId])
197
+ return 0;
198
+ if (registry.projects[newId]) {
199
+ throw new Error(`Project '${newId}' already exists.`);
200
+ }
201
+ const entry = registry.projects[oldId];
202
+ delete registry.projects[oldId];
203
+ registry.projects[newId] = entry;
204
+ await writeRegistry(registry);
205
+ for (const projectId of Object.keys(registry.projects)) {
206
+ const manifestId = projectId === newId ? oldId : projectId;
207
+ const manifest = await readProjectManifest(manifestId);
208
+ if (!manifest)
209
+ continue;
210
+ let changed = false;
211
+ if (projectId === newId && manifest.id !== newId) {
212
+ manifest.id = newId;
213
+ changed = true;
214
+ }
215
+ if (Array.isArray(manifest.relations)) {
216
+ const relations = manifest.relations.map((relation) => {
217
+ if (relation.targetId !== oldId)
218
+ return relation;
219
+ changed = true;
220
+ return { ...relation, targetId: newId };
221
+ });
222
+ manifest.relations = relations;
223
+ }
224
+ if (changed) {
225
+ await fs.writeFile(NexusPaths.projectManifestPath(manifestId), JSON.stringify(manifest, null, 2), "utf-8");
226
+ }
227
+ }
228
+ return 1;
229
+ }
230
+ }
@@ -0,0 +1,311 @@
1
+ import { getDatabase, initDatabase } from "./sqlite.js";
2
+ /**
3
+ * SQLite-backed Meeting Store
4
+ * Provides ACID-compliant concurrent access to meeting data
5
+ */
6
+ export class SqliteMeetingStore {
7
+ /**
8
+ * Initialize the database
9
+ */
10
+ static init() {
11
+ initDatabase();
12
+ }
13
+ /**
14
+ * Generate a unique meeting ID
15
+ */
16
+ static generateMeetingId(topic) {
17
+ const now = new Date();
18
+ const timestamp = now.toISOString().replace(/[-:T]/g, "").substring(0, 14);
19
+ // Create slug from topic, fallback to base64 hash for non-ASCII
20
+ let slug = topic
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, "-")
23
+ .replace(/^-|-$/g, "")
24
+ .substring(0, 30);
25
+ // If slug is empty (e.g., Chinese topic), use base64 of topic
26
+ if (!slug) {
27
+ slug = Buffer.from(topic).toString("base64").replace(/[^a-zA-Z0-9]/g, "").substring(0, 8).toLowerCase();
28
+ }
29
+ // Add random suffix for uniqueness (prevents collision in same second)
30
+ const suffix = Math.random().toString(36).substring(2, 6);
31
+ return `${timestamp}-${slug || "meeting"}-${suffix}`;
32
+ }
33
+ /**
34
+ * Start a new meeting
35
+ */
36
+ static startMeeting(topic, initiator) {
37
+ const db = getDatabase();
38
+ const id = this.generateMeetingId(topic);
39
+ const now = new Date().toISOString();
40
+ const participants = JSON.stringify([initiator]);
41
+ const stmt = db.prepare(`
42
+ INSERT INTO meetings (id, topic, status, initiator, participants, created_at)
43
+ VALUES (?, ?, 'active', ?, ?, ?)
44
+ `);
45
+ stmt.run(id, topic, initiator, participants, now);
46
+ // Update state
47
+ this.updateState("default_meeting", id);
48
+ const activeMeetings = this.getActiveMeetingIds();
49
+ activeMeetings.push(id);
50
+ this.updateState("active_meetings", JSON.stringify(activeMeetings));
51
+ return {
52
+ id,
53
+ topic,
54
+ status: "active",
55
+ startTime: now,
56
+ initiator,
57
+ participants: [initiator],
58
+ messages: [],
59
+ decisions: []
60
+ };
61
+ }
62
+ /**
63
+ * Get a meeting by ID
64
+ */
65
+ static getMeeting(id) {
66
+ const db = getDatabase();
67
+ const meetingStmt = db.prepare("SELECT * FROM meetings WHERE id = ?");
68
+ const meeting = meetingStmt.get(id);
69
+ if (!meeting)
70
+ return null;
71
+ // Get messages
72
+ const messagesStmt = db.prepare(`
73
+ SELECT sender as "from", text, category, timestamp
74
+ FROM messages WHERE meeting_id = ?
75
+ ORDER BY timestamp ASC
76
+ `);
77
+ const messages = messagesStmt.all(id);
78
+ // Get decisions
79
+ const decisionsStmt = db.prepare(`
80
+ SELECT content FROM decisions WHERE meeting_id = ?
81
+ ORDER BY timestamp ASC
82
+ `);
83
+ const decisions = decisionsStmt.all(id).map(d => d.content);
84
+ return {
85
+ id: meeting.id,
86
+ topic: meeting.topic,
87
+ status: meeting.status,
88
+ startTime: meeting.created_at,
89
+ endTime: meeting.closed_at || undefined,
90
+ initiator: meeting.initiator || "Unknown",
91
+ participants: JSON.parse(meeting.participants),
92
+ messages,
93
+ decisions,
94
+ summary: meeting.summary || undefined
95
+ };
96
+ }
97
+ /**
98
+ * Add a message to a meeting
99
+ */
100
+ static addMessage(meetingId, message) {
101
+ const db = getDatabase();
102
+ // Check meeting status
103
+ const checkStmt = db.prepare("SELECT status, participants FROM meetings WHERE id = ?");
104
+ const meeting = checkStmt.get(meetingId);
105
+ if (!meeting)
106
+ throw new Error(`Meeting '${meetingId}' not found.`);
107
+ if (meeting.status !== "active")
108
+ throw new Error(`Meeting '${meetingId}' is ${meeting.status}, cannot add messages.`);
109
+ // Insert message
110
+ const insertStmt = db.prepare(`
111
+ INSERT INTO messages (meeting_id, sender, text, category, timestamp)
112
+ VALUES (?, ?, ?, ?, ?)
113
+ `);
114
+ insertStmt.run(meetingId, message.from, message.text, message.category || null, message.timestamp);
115
+ // Track participant
116
+ const participants = JSON.parse(meeting.participants);
117
+ if (!participants.includes(message.from)) {
118
+ participants.push(message.from);
119
+ const updateStmt = db.prepare("UPDATE meetings SET participants = ? WHERE id = ?");
120
+ updateStmt.run(JSON.stringify(participants), meetingId);
121
+ }
122
+ // Extract decision
123
+ if (message.category === "DECISION") {
124
+ const decisionStmt = db.prepare(`
125
+ INSERT INTO decisions (meeting_id, content, timestamp)
126
+ VALUES (?, ?, ?)
127
+ `);
128
+ decisionStmt.run(meetingId, message.text, message.timestamp);
129
+ }
130
+ }
131
+ /**
132
+ * End a meeting
133
+ */
134
+ static endMeeting(meetingId, summary, callerId) {
135
+ const db = getDatabase();
136
+ const now = new Date().toISOString();
137
+ // Check meeting exists and is active
138
+ const meeting = this.getMeeting(meetingId);
139
+ if (!meeting)
140
+ throw new Error(`Meeting '${meetingId}' not found.`);
141
+ if (meeting.status !== "active")
142
+ throw new Error(`Meeting '${meetingId}' is already ${meeting.status}.`);
143
+ // Permission check: Only initiator can end
144
+ if (callerId && meeting.initiator && meeting.initiator !== callerId) {
145
+ throw new Error(`Permission denied: Only initiator (${meeting.initiator}) can end this meeting.`);
146
+ }
147
+ // Update meeting
148
+ const stmt = db.prepare(`
149
+ UPDATE meetings SET status = 'closed', closed_at = ?, summary = ?
150
+ WHERE id = ?
151
+ `);
152
+ stmt.run(now, summary || null, meetingId);
153
+ // Update state
154
+ const activeMeetings = this.getActiveMeetingIds().filter(id => id !== meetingId);
155
+ this.updateState("active_meetings", JSON.stringify(activeMeetings));
156
+ this.updateState("default_meeting", activeMeetings.length > 0 ? activeMeetings[activeMeetings.length - 1] : "");
157
+ // Refresh meeting data
158
+ const updatedMeeting = this.getMeeting(meetingId);
159
+ // Suggest sync targets based on participants
160
+ const suggestedSyncTargets = updatedMeeting.participants
161
+ .map(p => p.split("@")[1])
162
+ .filter((v, i, a) => v && v !== "Global" && a.indexOf(v) === i);
163
+ return { meeting: updatedMeeting, suggestedSyncTargets };
164
+ }
165
+ /**
166
+ * Archive a meeting
167
+ */
168
+ static archiveMeeting(meetingId, callerId) {
169
+ const db = getDatabase();
170
+ const meeting = this.getMeeting(meetingId);
171
+ if (!meeting)
172
+ throw new Error(`Meeting '${meetingId}' not found.`);
173
+ if (meeting.status === "active")
174
+ throw new Error(`Meeting '${meetingId}' is still active. End it first.`);
175
+ // Permission check: Only initiator can archive
176
+ if (callerId && meeting.initiator && meeting.initiator !== callerId) {
177
+ throw new Error(`Permission denied: Only initiator (${meeting.initiator}) can archive this meeting.`);
178
+ }
179
+ const stmt = db.prepare("UPDATE meetings SET status = 'archived' WHERE id = ?");
180
+ stmt.run(meetingId);
181
+ }
182
+ /**
183
+ * Reopen a closed or archived meeting
184
+ */
185
+ static reopenMeeting(meetingId, _callerId) {
186
+ const db = getDatabase();
187
+ const meeting = this.getMeeting(meetingId);
188
+ if (!meeting)
189
+ throw new Error(`Meeting '${meetingId}' not found.`);
190
+ if (meeting.status === "active")
191
+ throw new Error(`Meeting '${meetingId}' is already active.`);
192
+ // Update status to active
193
+ const stmt = db.prepare("UPDATE meetings SET status = 'active', closed_at = NULL WHERE id = ?");
194
+ stmt.run(meetingId);
195
+ // Update state
196
+ const activeMeetings = this.getActiveMeetingIds();
197
+ if (!activeMeetings.includes(meetingId)) {
198
+ activeMeetings.push(meetingId);
199
+ this.updateState("active_meetings", JSON.stringify(activeMeetings));
200
+ }
201
+ this.updateState("default_meeting", meetingId);
202
+ return this.getMeeting(meetingId);
203
+ }
204
+ /**
205
+ * List meetings with optional status filter
206
+ */
207
+ static listMeetings(status) {
208
+ const db = getDatabase();
209
+ let query = "SELECT id, topic, status, participants, created_at FROM meetings";
210
+ const params = [];
211
+ if (status) {
212
+ query += " WHERE status = ?";
213
+ params.push(status);
214
+ }
215
+ query += " ORDER BY created_at DESC";
216
+ const stmt = db.prepare(query);
217
+ const meetings = (params.length > 0 ? stmt.all(...params) : stmt.all());
218
+ return meetings.map(m => ({
219
+ id: m.id,
220
+ topic: m.topic,
221
+ status: m.status,
222
+ startTime: m.created_at,
223
+ participantCount: JSON.parse(m.participants).length
224
+ }));
225
+ }
226
+ /**
227
+ * Get the current active meeting
228
+ */
229
+ static getActiveMeeting() {
230
+ const defaultId = this.getState("default_meeting");
231
+ if (!defaultId)
232
+ return null;
233
+ return this.getMeeting(defaultId);
234
+ }
235
+ /**
236
+ * Get recent messages from a meeting (incremental read based on instance cursor)
237
+ * Automatically returns only unread messages for the current instance.
238
+ */
239
+ static getRecentMessages(count = 10, meetingId, instanceId) {
240
+ const db = getDatabase();
241
+ const targetId = meetingId || this.getState("default_meeting");
242
+ if (!targetId)
243
+ return [];
244
+ // If no instanceId provided, fall back to returning all recent messages
245
+ if (!instanceId) {
246
+ const stmt = db.prepare(`
247
+ SELECT id, sender as "from", text, category, timestamp
248
+ FROM messages WHERE meeting_id = ?
249
+ ORDER BY id DESC LIMIT ?
250
+ `);
251
+ const messages = stmt.all(targetId, count);
252
+ return messages.reverse();
253
+ }
254
+ // Get cursor for this instance + meeting
255
+ const cursorStmt = db.prepare(`
256
+ SELECT last_read_id FROM read_cursors
257
+ WHERE instance_id = ? AND context_type = 'meeting' AND context_id = ?
258
+ `);
259
+ const cursorRow = cursorStmt.get(instanceId, targetId);
260
+ const lastReadId = cursorRow?.last_read_id || 0;
261
+ // Fetch unread messages (id > lastReadId)
262
+ const stmt = db.prepare(`
263
+ SELECT id, sender as "from", text, category, timestamp
264
+ FROM messages
265
+ WHERE meeting_id = ? AND id > ?
266
+ ORDER BY id ASC
267
+ LIMIT ?
268
+ `);
269
+ const messages = stmt.all(targetId, lastReadId, count);
270
+ // Update cursor if we got messages
271
+ if (messages.length > 0) {
272
+ const maxId = messages[messages.length - 1].id;
273
+ const now = new Date().toISOString();
274
+ const updateStmt = db.prepare(`
275
+ INSERT OR REPLACE INTO read_cursors (instance_id, context_type, context_id, last_read_id, updated_at)
276
+ VALUES (?, 'meeting', ?, ?, ?)
277
+ `);
278
+ updateStmt.run(instanceId, targetId, maxId, now);
279
+ }
280
+ return messages;
281
+ }
282
+ /**
283
+ * Get meeting state value
284
+ */
285
+ static getState(key) {
286
+ const db = getDatabase();
287
+ const stmt = db.prepare("SELECT value FROM meeting_state WHERE key = ?");
288
+ const row = stmt.get(key);
289
+ return row?.value || "";
290
+ }
291
+ /**
292
+ * Update meeting state value
293
+ */
294
+ static updateState(key, value) {
295
+ const db = getDatabase();
296
+ const stmt = db.prepare("INSERT OR REPLACE INTO meeting_state (key, value) VALUES (?, ?)");
297
+ stmt.run(key, value);
298
+ }
299
+ /**
300
+ * Get list of active meeting IDs
301
+ */
302
+ static getActiveMeetingIds() {
303
+ const value = this.getState("active_meetings");
304
+ try {
305
+ return JSON.parse(value) || [];
306
+ }
307
+ catch {
308
+ return [];
309
+ }
310
+ }
311
+ }