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/build/index.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { pkg } from "./config/index.js";
3
+ const command = process.argv[2] || "mcp";
4
+ if (command === "--version" || command === "-v") {
5
+ process.stdout.write(pkg.version + "\n");
6
+ process.exit(0);
7
+ }
8
+ if (command === "--help" || command === "-h") {
9
+ process.stdout.write(`
10
+ n2n-nexus v${pkg.version} — Multi-AI assistant coordination hub
11
+
12
+ USAGE:
13
+ n2n-nexus daemon Start the Nexus server (run once, stays alive)
14
+ n2n-nexus mcp Start the MCP proxy (launched by IDE automatically)
15
+
16
+ OPTIONS:
17
+ --root <path> Storage directory for daemon (default: ~/.n2n-nexus)
18
+ --port <port> HTTP port for daemon (default: 5688)
19
+ --host <host> Host address for daemon (default: 127.0.0.1)
20
+ --id <id> Instance ID for MCP proxy
21
+ --version, -v Show version
22
+ --help, -h Show this help
23
+
24
+ ENVIRONMENT:
25
+ NEXUS_ROOT Override --root
26
+ NEXUS_DAEMON_PORT Override --port
27
+ NEXUS_HOST Override --host
28
+ NEXUS_ENDPOINT Daemon URL for MCP proxy (default: http://127.0.0.1:5688)
29
+ NEXUS_INSTANCE_ID Override --id for MCP proxy
30
+ `);
31
+ process.exit(0);
32
+ }
33
+ async function main() {
34
+ if (command === "daemon") {
35
+ const { runDaemon } = await import("./daemon/index.js");
36
+ await runDaemon();
37
+ return;
38
+ }
39
+ // Default: mcp
40
+ const { NexusServer } = await import("./server/nexus.js");
41
+ const server = new NexusServer();
42
+ await server.run();
43
+ }
44
+ main().catch(err => {
45
+ console.error("[n2n-nexus] Fatal error:", err);
46
+ process.exit(1);
47
+ });
@@ -0,0 +1,98 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ import { NexusClient } from "../client/nexus-client.js";
5
+ import { pkg } from "../config/index.js";
6
+ const RETRY_INTERVAL_MS = 3000;
7
+ const SERVICE_NAME = "n2n-nexus";
8
+ export class NexusServer {
9
+ server;
10
+ client;
11
+ instanceId;
12
+ cachedTools = [];
13
+ retryTimer = null;
14
+ connected = false;
15
+ constructor() {
16
+ const endpoint = process.env.NEXUS_ENDPOINT || "http://127.0.0.1:5688";
17
+ const instanceId = process.env.NEXUS_INSTANCE_ID ||
18
+ process.argv.find((_, i) => process.argv[i - 1] === "--id") ||
19
+ `mcp-${Math.random().toString(36).slice(2, 6)}`;
20
+ this.instanceId = instanceId;
21
+ this.client = new NexusClient({ endpoint, timeoutMs: 5000 });
22
+ this.server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { tools: { listChanged: true } } });
23
+ this.setupHandlers();
24
+ }
25
+ setupHandlers() {
26
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
+ tools: this.cachedTools
28
+ }));
29
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
30
+ if (!this.connected) {
31
+ return {
32
+ content: [{ type: "text", text: "Daemon is not ready yet. Please wait a moment and retry." }],
33
+ isError: true
34
+ };
35
+ }
36
+ try {
37
+ const result = await this.client.callTool(request.params.name, request.params.arguments, this.instanceId);
38
+ return {
39
+ content: [{
40
+ type: "text",
41
+ text: typeof result === "string" ? result : JSON.stringify(result, null, 2)
42
+ }]
43
+ };
44
+ }
45
+ catch (e) {
46
+ const message = e instanceof Error ? e.message : String(e);
47
+ // Daemon went away — start retry loop
48
+ if (this.connected) {
49
+ this.connected = false;
50
+ this.cachedTools = [];
51
+ await this.server.sendToolListChanged();
52
+ this.scheduleRetry();
53
+ }
54
+ return {
55
+ content: [{ type: "text", text: `Daemon unavailable: ${message}. Reconnecting...` }],
56
+ isError: true
57
+ };
58
+ }
59
+ });
60
+ }
61
+ async tryConnect() {
62
+ try {
63
+ const tools = await this.client.fetchTools();
64
+ this.cachedTools = tools;
65
+ this.connected = true;
66
+ console.error(`[n2n-nexus] Connected to daemon. ${tools.length} tools loaded.`);
67
+ await this.server.sendToolListChanged();
68
+ return true;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ scheduleRetry() {
75
+ if (this.retryTimer)
76
+ return;
77
+ const endpoint = process.env.NEXUS_ENDPOINT || "http://127.0.0.1:5688";
78
+ this.retryTimer = setInterval(async () => {
79
+ console.error(`[n2n-nexus] Waiting for daemon at ${endpoint}...`);
80
+ const ok = await this.tryConnect();
81
+ if (ok && this.retryTimer) {
82
+ clearInterval(this.retryTimer);
83
+ this.retryTimer = null;
84
+ }
85
+ }, RETRY_INTERVAL_MS);
86
+ }
87
+ async run() {
88
+ const transport = new StdioServerTransport();
89
+ await this.server.connect(transport);
90
+ // Try to connect immediately; if not ready, start retry loop
91
+ const ok = await this.tryConnect();
92
+ if (!ok) {
93
+ const endpoint = process.env.NEXUS_ENDPOINT || "http://127.0.0.1:5688";
94
+ console.error(`[n2n-nexus] Daemon not available at ${endpoint}. Will retry every ${RETRY_INTERVAL_MS / 1000}s...`);
95
+ this.scheduleRetry();
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,74 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { NexusPaths } from "./paths.js";
3
+ import { FILE_ENCODING } from "../constants.js";
4
+ export class DocStorage {
5
+ static async ensureDirectory() {
6
+ await fs.mkdir(NexusPaths.docsDir(), { recursive: true });
7
+ try {
8
+ await fs.access(NexusPaths.docsIndexFile);
9
+ }
10
+ catch {
11
+ await fs.writeFile(NexusPaths.docsIndexFile, JSON.stringify({}, null, 2), FILE_ENCODING);
12
+ }
13
+ }
14
+ static async readIndex() {
15
+ await this.ensureDirectory();
16
+ try {
17
+ const raw = await fs.readFile(NexusPaths.docsIndexFile, FILE_ENCODING);
18
+ const parsed = JSON.parse(raw);
19
+ if (parsed && typeof parsed === "object")
20
+ return parsed;
21
+ }
22
+ catch {
23
+ // fall through
24
+ }
25
+ return {};
26
+ }
27
+ static async writeIndex(index) {
28
+ await this.ensureDirectory();
29
+ await fs.writeFile(NexusPaths.docsIndexFile, JSON.stringify(index, null, 2), FILE_ENCODING);
30
+ }
31
+ static async init() {
32
+ await this.ensureDirectory();
33
+ try {
34
+ await fs.access(NexusPaths.globalBlueprint);
35
+ }
36
+ catch {
37
+ await fs.writeFile(NexusPaths.globalBlueprint, "# Global Collaboration Blueprint\n\nShared meeting space.", FILE_ENCODING);
38
+ }
39
+ }
40
+ static async listDocs() {
41
+ await this.ensureDirectory();
42
+ return this.readIndex();
43
+ }
44
+ static async getDoc(docId) {
45
+ await this.ensureDirectory();
46
+ const filePath = NexusPaths.docFile(docId);
47
+ try {
48
+ return await fs.readFile(filePath, FILE_ENCODING);
49
+ }
50
+ catch {
51
+ return "";
52
+ }
53
+ }
54
+ static async saveDoc(docId, title, content, updatedBy) {
55
+ await this.ensureDirectory();
56
+ const index = await this.readIndex();
57
+ const filePath = NexusPaths.docFile(docId);
58
+ await fs.writeFile(filePath, content, FILE_ENCODING);
59
+ index[docId] = {
60
+ title,
61
+ lastUpdated: new Date().toISOString(),
62
+ updatedBy
63
+ };
64
+ await this.writeIndex(index);
65
+ }
66
+ static async deleteDoc(docId) {
67
+ await this.ensureDirectory();
68
+ const index = await this.readIndex();
69
+ const filePath = NexusPaths.docFile(docId);
70
+ delete index[docId];
71
+ await this.writeIndex(index);
72
+ await fs.rm(filePath, { force: true });
73
+ }
74
+ }
@@ -0,0 +1,105 @@
1
+ import { promises as fs } from "fs";
2
+ import { FILE_ENCODING } from "../constants.js";
3
+ import { NexusPaths } from "./paths.js";
4
+ import { RegistryStorage } from "./registry.js";
5
+ import { ProjectStorage } from "./projects.js";
6
+ import { LogStorage } from "./logs.js";
7
+ import { DocStorage } from "./docs.js";
8
+ export class StorageManager {
9
+ static initialized = false;
10
+ // --- Path Proxies (Backward Compatibility) ---
11
+ static get globalDir() { return NexusPaths.globalDir; }
12
+ static get globalBlueprint() { return NexusPaths.globalBlueprint; }
13
+ static get globalDiscussion() { return NexusPaths.globalDiscussion; }
14
+ static get projectsRoot() { return NexusPaths.projectsRoot; }
15
+ static get registryFile() { return NexusPaths.registryFile; }
16
+ static get archivesDir() { return NexusPaths.archivesDir; }
17
+ static async init() {
18
+ if (this.initialized)
19
+ return;
20
+ await fs.mkdir(NexusPaths.root, { recursive: true });
21
+ await fs.mkdir(this.globalDir, { recursive: true });
22
+ await fs.mkdir(this.projectsRoot, { recursive: true });
23
+ await fs.mkdir(this.archivesDir, { recursive: true });
24
+ // Self-healing initialization for critical files
25
+ await this.loadJsonSafe(this.registryFile, { projects: {} });
26
+ await this.loadJsonSafe(this.globalDiscussion, []);
27
+ if (!await this.exists(this.globalBlueprint)) {
28
+ await fs.writeFile(this.globalBlueprint, "# Global Coordination Blueprint\n\nShared meeting space.");
29
+ }
30
+ await DocStorage.init();
31
+ try {
32
+ const { initTasksTable } = await import("./tasks.js");
33
+ initTasksTable();
34
+ }
35
+ catch {
36
+ /* tasks module is optional */
37
+ }
38
+ this.initialized = true;
39
+ }
40
+ static resetInit() {
41
+ this.initialized = false;
42
+ }
43
+ static async loadJsonSafe(filePath, defaultValue) {
44
+ try {
45
+ if (!await this.exists(filePath)) {
46
+ await fs.writeFile(filePath, JSON.stringify(defaultValue, null, 2), FILE_ENCODING);
47
+ return defaultValue;
48
+ }
49
+ const content = await fs.readFile(filePath, FILE_ENCODING);
50
+ return JSON.parse(content.replace(/^\uFEFF/, '').trim());
51
+ }
52
+ catch {
53
+ console.warn(`[Nexus Storage] Repairing corrupted file: ${filePath}`);
54
+ await fs.writeFile(filePath, JSON.stringify(defaultValue, null, 2), FILE_ENCODING);
55
+ return defaultValue;
56
+ }
57
+ }
58
+ static async exists(p) {
59
+ try {
60
+ await fs.access(p);
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ // --- Registry Methods ---
68
+ static listRegistry() { return RegistryStorage.listRegistry(); }
69
+ static getProjectManifest(id) { return RegistryStorage.getProjectManifest(id); }
70
+ static saveProjectManifest(manifest) { return RegistryStorage.saveProjectManifest(manifest); }
71
+ static patchProjectManifest(id, patch) { return RegistryStorage.patchProjectManifest(id, patch); }
72
+ static calculateTopology(projectId) { return RegistryStorage.calculateTopology(projectId); }
73
+ // --- Project Methods ---
74
+ static getProjectDocs(id) { return ProjectStorage.getProjectDocs(id); }
75
+ static saveProjectDocs(id, content) { return ProjectStorage.saveProjectDocs(id, content); }
76
+ static saveAsset(id, fileName, content) { return ProjectStorage.saveAsset(id, fileName, content); }
77
+ static async deleteProject(id) {
78
+ await ProjectStorage.deleteProject(id);
79
+ await RegistryStorage.deleteProject(id);
80
+ }
81
+ static async renameProject(oldId, newId) {
82
+ await RegistryStorage.renameProject(oldId, newId);
83
+ try {
84
+ const result = await ProjectStorage.renameProject(oldId, newId);
85
+ if (result === 0) {
86
+ await RegistryStorage.renameProject(newId, oldId);
87
+ }
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ await RegistryStorage.renameProject(newId, oldId).catch(() => { });
92
+ throw error;
93
+ }
94
+ }
95
+ // --- Log Methods ---
96
+ static addGlobalLog(from, text, category) { return LogStorage.addLog(from, text, category); }
97
+ static getRecentLogs(count = 10) { return LogStorage.getLogs(count); }
98
+ static pruneGlobalLogs(count) { return LogStorage.pruneLogs(count); }
99
+ static clearGlobalLogs() { return LogStorage.clearLogs(); }
100
+ // --- Doc Methods ---
101
+ static listGlobalDocs() { return DocStorage.listDocs(); }
102
+ static getGlobalDoc(docId) { return DocStorage.getDoc(docId); }
103
+ static saveGlobalDoc(docId, title, content, updatedBy) { return DocStorage.saveDoc(docId, title, content, updatedBy); }
104
+ static deleteGlobalDoc(docId) { return DocStorage.deleteDoc(docId); }
105
+ }
@@ -0,0 +1,60 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { NexusPaths } from "./paths.js";
3
+ export class LogStorage {
4
+ static async ensureLogFile() {
5
+ try {
6
+ await fs.access(NexusPaths.globalDiscussion);
7
+ }
8
+ catch {
9
+ await fs.writeFile(NexusPaths.globalDiscussion, "[]", "utf-8");
10
+ }
11
+ }
12
+ static async addLog(from, text, category) {
13
+ await this.ensureLogFile();
14
+ const logs = await this.getLogs();
15
+ const payload = {
16
+ timestamp: new Date().toISOString(),
17
+ from,
18
+ text,
19
+ category: this.normalizeCategory(category)
20
+ };
21
+ logs.push(payload);
22
+ await fs.writeFile(NexusPaths.globalDiscussion, JSON.stringify(logs, null, 2), "utf-8");
23
+ }
24
+ static normalizeCategory(category) {
25
+ if (!category)
26
+ return undefined;
27
+ const allowed = ["MEETING_START", "PROPOSAL", "DECISION", "UPDATE", "CHAT", "message", "SYSTEM"];
28
+ return allowed.includes(category) ? category : "UPDATE";
29
+ }
30
+ static async getLogs(limit = 10) {
31
+ await this.ensureLogFile();
32
+ const rows = await this.readRawLogs();
33
+ if (limit <= 0)
34
+ return rows;
35
+ return rows.slice(-limit);
36
+ }
37
+ static async pruneLogs(count) {
38
+ const current = await this.readRawLogs();
39
+ if (count <= 0)
40
+ return;
41
+ if (current.length <= count)
42
+ return;
43
+ const remaining = current.slice(count);
44
+ await fs.writeFile(NexusPaths.globalDiscussion, JSON.stringify(remaining, null, 2), "utf-8");
45
+ }
46
+ static async clearLogs() {
47
+ await fs.writeFile(NexusPaths.globalDiscussion, "[]", "utf-8");
48
+ }
49
+ static async readRawLogs() {
50
+ await this.ensureLogFile();
51
+ try {
52
+ const raw = await fs.readFile(NexusPaths.globalDiscussion, "utf-8");
53
+ const parsed = JSON.parse(raw);
54
+ return Array.isArray(parsed) ? parsed : [];
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,276 @@
1
+ import { promises as fs } from "fs";
2
+ import path from "path";
3
+ import { NexusPaths } from "./paths.js";
4
+ import { AsyncMutex } from "../utils/async-mutex.js";
5
+ import { FILE_ENCODING } from "../constants.js";
6
+ /**
7
+ * MeetingStore - Handles all meeting-related storage operations
8
+ */
9
+ export class MeetingStore {
10
+ static meetingLock = new AsyncMutex();
11
+ static stateLock = new AsyncMutex();
12
+ // --- Path Definitions ---
13
+ static get meetingsDir() { return path.join(NexusPaths.root, "meetings"); }
14
+ static get stateFile() { return path.join(NexusPaths.root, "global", "meeting_state.json"); }
15
+ /**
16
+ * Initialize meeting storage directories
17
+ */
18
+ static async init() {
19
+ await fs.mkdir(this.meetingsDir, { recursive: true });
20
+ await this.loadStateSafe();
21
+ }
22
+ /**
23
+ * Check if a path exists
24
+ */
25
+ static async exists(p) {
26
+ try {
27
+ await fs.access(p);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Load meeting state with self-healing
36
+ */
37
+ static async loadStateSafe() {
38
+ const defaultState = { activeMeetings: [], defaultMeetingId: null };
39
+ try {
40
+ if (!await this.exists(this.stateFile)) {
41
+ await fs.writeFile(this.stateFile, JSON.stringify(defaultState, null, 2), FILE_ENCODING);
42
+ return defaultState;
43
+ }
44
+ const content = await fs.readFile(this.stateFile, FILE_ENCODING);
45
+ const cleanContent = content.replace(/^\uFEFF/, '').trim();
46
+ if (!cleanContent)
47
+ throw new Error("Empty file");
48
+ return JSON.parse(cleanContent);
49
+ }
50
+ catch (e) {
51
+ console.warn(`[MeetingStore] Repairing corrupted state file. Error: ${e.message}`);
52
+ await fs.writeFile(this.stateFile, JSON.stringify(defaultState, null, 2), FILE_ENCODING);
53
+ return defaultState;
54
+ }
55
+ }
56
+ /**
57
+ * Get current meeting state
58
+ */
59
+ static async getState() {
60
+ return this.loadStateSafe();
61
+ }
62
+ /**
63
+ * Save meeting state
64
+ */
65
+ static async saveState(state) {
66
+ await fs.writeFile(this.stateFile, JSON.stringify(state, null, 2), FILE_ENCODING);
67
+ }
68
+ /**
69
+ * Generate a unique meeting ID
70
+ */
71
+ static generateMeetingId(topic) {
72
+ const now = new Date();
73
+ const timestamp = now.toISOString().replace(/[-:T]/g, '').substring(0, 14);
74
+ // Create slug from topic, fallback to base64 hash for non-ASCII
75
+ let slug = topic
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9]+/g, '-')
78
+ .replace(/^-|-$/g, '')
79
+ .substring(0, 30);
80
+ // If slug is empty (e.g., Chinese topic), use base64 of topic
81
+ if (!slug) {
82
+ slug = Buffer.from(topic).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8).toLowerCase();
83
+ }
84
+ // Add random suffix for uniqueness (prevents collision in same second)
85
+ const suffix = Math.random().toString(36).substring(2, 6);
86
+ return `${timestamp}-${slug || 'meeting'}-${suffix}`;
87
+ }
88
+ /**
89
+ * Get the file path for a meeting
90
+ */
91
+ static getMeetingPath(id) {
92
+ return path.join(this.meetingsDir, `${id}.json`);
93
+ }
94
+ /**
95
+ * Start a new meeting
96
+ */
97
+ static async startMeeting(topic, initiator) {
98
+ await this.init();
99
+ return this.stateLock.withLock(async () => {
100
+ const id = this.generateMeetingId(topic);
101
+ const meeting = {
102
+ id,
103
+ topic,
104
+ status: "active",
105
+ startTime: new Date().toISOString(),
106
+ initiator,
107
+ participants: [initiator],
108
+ messages: [],
109
+ decisions: []
110
+ };
111
+ // Save meeting file
112
+ await fs.writeFile(this.getMeetingPath(id), JSON.stringify(meeting, null, 2), FILE_ENCODING);
113
+ // Update state
114
+ const state = await this.loadStateSafe();
115
+ state.activeMeetings.push(id);
116
+ state.defaultMeetingId = id;
117
+ await this.saveState(state);
118
+ return meeting;
119
+ });
120
+ }
121
+ /**
122
+ * Get a meeting by ID
123
+ */
124
+ static async getMeeting(id) {
125
+ const meetingPath = this.getMeetingPath(id);
126
+ if (!await this.exists(meetingPath))
127
+ return null;
128
+ const content = await fs.readFile(meetingPath, FILE_ENCODING);
129
+ return JSON.parse(content);
130
+ }
131
+ /**
132
+ * Add a message to a meeting
133
+ */
134
+ static async addMessage(meetingId, message) {
135
+ await this.meetingLock.withLock(async () => {
136
+ const meeting = await this.getMeeting(meetingId);
137
+ if (!meeting)
138
+ throw new Error(`Meeting '${meetingId}' not found.`);
139
+ if (meeting.status !== "active")
140
+ throw new Error(`Meeting '${meetingId}' is ${meeting.status}, cannot add messages.`);
141
+ // Add message
142
+ meeting.messages.push(message);
143
+ // Track participant
144
+ if (!meeting.participants.includes(message.from)) {
145
+ meeting.participants.push(message.from);
146
+ }
147
+ // Extract decisions
148
+ if (message.category === "DECISION") {
149
+ meeting.decisions.push(message.text);
150
+ }
151
+ await fs.writeFile(this.getMeetingPath(meetingId), JSON.stringify(meeting, null, 2), FILE_ENCODING);
152
+ });
153
+ }
154
+ /**
155
+ * End a meeting (close it)
156
+ */
157
+ static async endMeeting(meetingId, summary, callerId) {
158
+ return this.stateLock.withLock(async () => {
159
+ const meeting = await this.getMeeting(meetingId);
160
+ if (!meeting)
161
+ throw new Error(`Meeting '${meetingId}' not found.`);
162
+ if (meeting.status !== "active")
163
+ throw new Error(`Meeting '${meetingId}' is already ${meeting.status}.`);
164
+ // Permission check: Only initiator can end
165
+ if (callerId && meeting.initiator && meeting.initiator !== callerId) {
166
+ throw new Error(`Permission denied: Only initiator (${meeting.initiator}) can end this meeting.`);
167
+ }
168
+ // Close the meeting
169
+ meeting.status = "closed";
170
+ meeting.endTime = new Date().toISOString();
171
+ if (summary)
172
+ meeting.summary = summary;
173
+ await fs.writeFile(this.getMeetingPath(meetingId), JSON.stringify(meeting, null, 2), FILE_ENCODING);
174
+ // Update state - remove from active meetings
175
+ const state = await this.loadStateSafe();
176
+ state.activeMeetings = state.activeMeetings.filter(id => id !== meetingId);
177
+ state.defaultMeetingId = state.activeMeetings.length > 0
178
+ ? state.activeMeetings[state.activeMeetings.length - 1]
179
+ : null;
180
+ await this.saveState(state);
181
+ // Suggest sync targets based on participants (extract project IDs)
182
+ const suggestedSyncTargets = meeting.participants
183
+ .map(p => p.split('@')[1])
184
+ .filter((v, i, a) => v && v !== "Global" && a.indexOf(v) === i);
185
+ return { meeting, suggestedSyncTargets };
186
+ });
187
+ }
188
+ /**
189
+ * Archive a closed meeting
190
+ */
191
+ static async archiveMeeting(meetingId, callerId) {
192
+ const meeting = await this.getMeeting(meetingId);
193
+ if (!meeting)
194
+ throw new Error(`Meeting '${meetingId}' not found.`);
195
+ if (meeting.status === "active")
196
+ throw new Error(`Meeting '${meetingId}' is still active. End it first.`);
197
+ // Permission check: Only initiator can archive
198
+ if (callerId && meeting.initiator && meeting.initiator !== callerId) {
199
+ throw new Error(`Permission denied: Only initiator (${meeting.initiator}) can archive this meeting.`);
200
+ }
201
+ meeting.status = "archived";
202
+ await fs.writeFile(this.getMeetingPath(meetingId), JSON.stringify(meeting, null, 2), FILE_ENCODING);
203
+ }
204
+ /**
205
+ * Reopen a closed or archived meeting
206
+ */
207
+ static async reopenMeeting(meetingId, _callerId) {
208
+ return this.stateLock.withLock(async () => {
209
+ const meeting = await this.getMeeting(meetingId);
210
+ if (!meeting)
211
+ throw new Error(`Meeting '${meetingId}' not found.`);
212
+ if (meeting.status === "active")
213
+ throw new Error(`Meeting '${meetingId}' is already active.`);
214
+ // Update status to active
215
+ meeting.status = "active";
216
+ meeting.endTime = undefined;
217
+ await fs.writeFile(this.getMeetingPath(meetingId), JSON.stringify(meeting, null, 2), FILE_ENCODING);
218
+ // Update state
219
+ const state = await this.loadStateSafe();
220
+ if (!state.activeMeetings.includes(meetingId)) {
221
+ state.activeMeetings.push(meetingId);
222
+ }
223
+ state.defaultMeetingId = meetingId;
224
+ await this.saveState(state);
225
+ return meeting;
226
+ });
227
+ }
228
+ /**
229
+ * List all meetings with optional status filter
230
+ */
231
+ static async listMeetings(status) {
232
+ await this.init();
233
+ const files = await fs.readdir(this.meetingsDir);
234
+ const meetings = [];
235
+ for (const file of files) {
236
+ if (!file.endsWith('.json'))
237
+ continue;
238
+ const id = file.replace('.json', '');
239
+ const meeting = await this.getMeeting(id);
240
+ if (!meeting)
241
+ continue;
242
+ if (status && meeting.status !== status)
243
+ continue;
244
+ meetings.push({
245
+ id: meeting.id,
246
+ topic: meeting.topic,
247
+ status: meeting.status,
248
+ startTime: meeting.startTime,
249
+ participantCount: meeting.participants.length
250
+ });
251
+ }
252
+ // Sort by startTime descending
253
+ return meetings.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
254
+ }
255
+ /**
256
+ * Get the current active meeting (for auto-routing)
257
+ */
258
+ static async getActiveMeeting() {
259
+ const state = await this.getState();
260
+ if (!state.defaultMeetingId)
261
+ return null;
262
+ return this.getMeeting(state.defaultMeetingId);
263
+ }
264
+ /**
265
+ * Get recent messages from the active meeting
266
+ */
267
+ static async getRecentMessages(count = 10, meetingId) {
268
+ const targetId = meetingId || (await this.getState()).defaultMeetingId;
269
+ if (!targetId)
270
+ return [];
271
+ const meeting = await this.getMeeting(targetId);
272
+ if (!meeting)
273
+ return [];
274
+ return meeting.messages.slice(-count);
275
+ }
276
+ }
@@ -0,0 +1,26 @@
1
+ import path from "node:path";
2
+ import { getRootPath } from "../config/paths.js";
3
+ export class NexusPaths {
4
+ static get root() { return getRootPath(); }
5
+ static get globalDir() { return path.join(this.root, "global"); }
6
+ static get globalBlueprint() { return path.join(this.globalDir, "blueprint.md"); }
7
+ static get globalDiscussion() { return path.join(this.globalDir, "discussion.json"); }
8
+ static get projectsRoot() { return path.join(this.root, "projects"); }
9
+ static get registryFile() { return path.join(this.root, "registry.json"); }
10
+ static get archivesDir() { return path.join(this.root, "archives"); }
11
+ static assertSafeSegment(value, label) {
12
+ if (!/^[a-zA-Z0-9._-]+$/.test(value) || value === "." || value === "..") {
13
+ throw new Error(`Invalid ${label}: '${value}'. Use only letters, numbers, dots, underscores, and hyphens.`);
14
+ }
15
+ return value;
16
+ }
17
+ static projectDir(id) { return path.join(this.projectsRoot, this.assertSafeSegment(id, "project id")); }
18
+ static projectDocPath(id) { return path.join(this.projectDir(id), "internal_blueprint.md"); }
19
+ static projectManifestPath(id) { return path.join(this.projectDir(id), "manifest.json"); }
20
+ static projectAssetsDir(id) { return path.join(this.projectDir(id), "assets"); }
21
+ static get dbFile() { return path.join(this.root, "nexus.db"); }
22
+ static taskFile() { return path.join(this.root, "tasks.json"); }
23
+ static docsDir() { return path.join(this.globalDir, "docs"); }
24
+ static get docsIndexFile() { return path.join(this.globalDir, "docs_index.json"); }
25
+ static docFile(docId) { return path.join(this.docsDir(), `${this.assertSafeSegment(docId, "document id")}.md`); }
26
+ }