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
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
|
+
}
|