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.
- package/CHANGELOG.md +358 -0
- package/LICENSE +201 -0
- package/README.md +286 -0
- package/build/client/nexus-client.js +71 -0
- package/build/config/cli.js +11 -0
- package/build/config/index.js +16 -0
- package/build/config/paths.js +38 -0
- package/build/constants.js +22 -0
- package/build/daemon/index.js +41 -0
- package/build/daemon/server.js +791 -0
- package/build/index.js +47 -0
- package/build/server/nexus.js +98 -0
- package/build/storage/docs.js +74 -0
- package/build/storage/index.js +105 -0
- package/build/storage/logs.js +60 -0
- package/build/storage/meetings.js +276 -0
- package/build/storage/paths.js +26 -0
- package/build/storage/projects.js +75 -0
- package/build/storage/registry.js +230 -0
- package/build/storage/sqlite-meeting.js +311 -0
- package/build/storage/sqlite.js +141 -0
- package/build/storage/store.js +153 -0
- package/build/storage/tasks.js +212 -0
- package/build/types.js +1 -0
- package/build/utils/async-mutex.js +36 -0
- package/docs/ARCHITECTURE.md +205 -0
- package/docs/ARCHITECTURE_zh.md +205 -0
- package/docs/ASSISTANT_GUIDE.md +120 -0
- package/docs/README_zh.md +285 -0
- package/llms.txt +46 -0
- package/package.json +90 -0
|
@@ -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
|
+
}
|