@datafrog-io/n2n-nexus 0.1.7 → 0.1.8
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/README.md +1 -0
- package/README_zh.md +143 -0
- package/build/index.js +1 -1
- package/build/resources/index.js +29 -1
- package/build/storage/index.js +1 -35
- package/build/storage/meetings.js +242 -0
- package/build/storage/sqlite-meeting.js +249 -0
- package/build/storage/sqlite.js +120 -0
- package/build/storage/store.js +139 -0
- package/build/tools/definitions.js +69 -9
- package/build/tools/handlers.js +153 -6
- package/build/utils/async-mutex.js +36 -0
- package/docs/ASSISTANT_GUIDE.md +66 -0
- package/docs/CHANGELOG.md +141 -0
- package/docs/CHANGELOG_zh.md +141 -0
- package/docs/MEETING_MINUTES_2025-12-29.md +160 -0
- package/docs/discussion_2025-12-29_en.json +330 -0
- package/docs/discussion_2025-12-29_en.md +717 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -94,6 +94,7 @@ To ensure clarity and prevent collisions in the flat local namespace, all Projec
|
|
|
94
94
|
|
|
95
95
|
### D. Admin (Moderator Only)
|
|
96
96
|
- `moderator_maintenance`: Prune or clear system logs.
|
|
97
|
+
- `moderator_delete_project`: Completely remove a project and its assets.
|
|
97
98
|
|
|
98
99
|
## 📄 Resources (URI)
|
|
99
100
|
|
package/README_zh.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# n2ns Nexus 🚀
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@datafrog-io/n2n-nexus)
|
|
4
|
+
[](https://www.npmjs.com/package/@datafrog-io/n2n-nexus)
|
|
5
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
6
|
+
[](https://github.com/n2ns/n2n-nexus)
|
|
7
|
+
|
|
8
|
+
**n2ns Nexus** 是一个专为多 AI 助手协同设计的“本地数字化资产中心”。它将高频的**实时会议室**与严谨的**结构化资产库**完美融合,提供 100% 本地化、零外部依赖的项目管理体验。
|
|
9
|
+
|
|
10
|
+
> **支持的 IDE:** VS Code · Cursor · Windsurf · Zed · JetBrains · Theia · Google Antigravity
|
|
11
|
+
|
|
12
|
+
## 🏛️ 系统架构 (Architecture)
|
|
13
|
+
|
|
14
|
+
1. **Nexus Room (讨论区)**: 所有 IDE 助手的统一公域频道,用于跨项目协调。
|
|
15
|
+
2. **Asset Vault (归档库)**:
|
|
16
|
+
- **Manifest**: 每个项目的技术细节、计费、拓扑关系、API 规范。
|
|
17
|
+
- **Internal Docs**: 每个项目的详细技术实施方案。
|
|
18
|
+
- **Assets**: 本地物理素材存储(Logo/UI 截图等)。
|
|
19
|
+
3. **Global Knowledge (全局知识库)**:
|
|
20
|
+
- **Master Strategy**: 顶层战略总纲。
|
|
21
|
+
- **Global Docs**: 跨项目的通用文档(如编码规范、路线图)。
|
|
22
|
+
4. **Topology Engine**: 自动分析项目间的依赖关系图谱。
|
|
23
|
+
|
|
24
|
+
## � 数据持久化 (Data Persistence)
|
|
25
|
+
|
|
26
|
+
Nexus 将所有数据存储在本地文件系统中(默认路径可配置),完全掌控数据主权。
|
|
27
|
+
|
|
28
|
+
**目录结构示例**:
|
|
29
|
+
```text
|
|
30
|
+
Nexus_Storage/
|
|
31
|
+
├── global/
|
|
32
|
+
│ ├── blueprint.md # Master Strategy
|
|
33
|
+
│ ├── discussion.json # Chat History
|
|
34
|
+
│ ├── docs_index.json # Global Docs Metadata
|
|
35
|
+
│ └── docs/ # Global Markdown Docs
|
|
36
|
+
│ ├── coding-standards.md
|
|
37
|
+
│ └── deployment-flow.md
|
|
38
|
+
├── projects/
|
|
39
|
+
│ ├── my-app/
|
|
40
|
+
│ │ ├── manifest.json # Project Metadata
|
|
41
|
+
│ │ ├── internal_blueprint.md
|
|
42
|
+
│ │ └── assets/ # Binary Assets
|
|
43
|
+
│ └── ...
|
|
44
|
+
├── registry.json # Global Project Index
|
|
45
|
+
└── archives/ # (Reserved for backups)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**自我修复 (Self-healing)**: 核心数据文件(如 `registry.json`, `discussion.json`)具备自动检测与修复机制。如果文件损坏或意外丢失,系统会自动重建初始状态,确保服务不中断。
|
|
49
|
+
|
|
50
|
+
## �🛠️ 工具集 (Toolset)
|
|
51
|
+
|
|
52
|
+
### A. 会话与上下文 (Session)
|
|
53
|
+
- `register_session_context`: 声明当前 IDE 工作的项目 ID,解锁写权限。
|
|
54
|
+
- `mcp://nexus/session`: 查看当前身份、角色(Moderator/Regular)及活动项目。
|
|
55
|
+
|
|
56
|
+
### B. 项目资产管理 (Project Assets)
|
|
57
|
+
- `sync_project_assets`: **[核心]** 提交完整的项目 Manifest 和内部技术文档。
|
|
58
|
+
- **Manifest**: 包含 ID、技术栈、**依赖关系 (Relations)**、仓库地址、本地路径、API Spec 等。
|
|
59
|
+
- `update_project`: 部分更新 Manifest 字段(如仅更新 endpoints 或 description)。
|
|
60
|
+
- `rename_project`: 重命名项目 ID,自动级联更新所有相关项目的依赖引用。
|
|
61
|
+
- `upload_project_asset`: 上传二进制/文本文件(Base64)到项目库。
|
|
62
|
+
- `read_project`: 读取项目的特定数据切片(Summary, Manifest, Docs, API, Relations等)。
|
|
63
|
+
|
|
64
|
+
### C. 全局协作 (Global Collaboration)
|
|
65
|
+
- `post_global_discussion`: 发送跨项目广播消息。
|
|
66
|
+
- `update_global_strategy`: 更新核心战略蓝图 (`# Master Plan`)。
|
|
67
|
+
- `get_global_topology`:获取全网项目依赖关系图谱。
|
|
68
|
+
- `sync_global_doc` / `list_global_docs` / `read_global_doc`: 管理全局通用文档。
|
|
69
|
+
|
|
70
|
+
### D. 管理员 (Moderator Only)
|
|
71
|
+
- `moderator_maintenance`: 清理或修剪系统日志。
|
|
72
|
+
- `moderator_delete_project`: 彻底删除项目及其所有资产。
|
|
73
|
+
|
|
74
|
+
## 📄 资源 URI (Resources)
|
|
75
|
+
|
|
76
|
+
- `mcp://chat/global`: 实时对话流历史。
|
|
77
|
+
- `mcp://hub/registry`: 全局项目注册表概览。
|
|
78
|
+
- `mcp://docs/global-strategy`: 战略总领文档。
|
|
79
|
+
- `mcp://nexus/session`: 当前会话状态。
|
|
80
|
+
- `mcp://hub/projects/{id}/manifest`: 特定项目的完整元数据。
|
|
81
|
+
- `mcp://hub/projects/{id}/internal-docs`: 特定项目的内部技术文档。
|
|
82
|
+
|
|
83
|
+
## 🚀 快速启动
|
|
84
|
+
|
|
85
|
+
### MCP 配置(推荐)
|
|
86
|
+
|
|
87
|
+
在你的 MCP 配置文件中(如 `claude_desktop_config.json` 或 Cursor MCP 设置)添加:
|
|
88
|
+
|
|
89
|
+
#### 主持者(管理员 AI)
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"n2n-nexus": {
|
|
94
|
+
"command": "npx",
|
|
95
|
+
"args": [
|
|
96
|
+
"-y",
|
|
97
|
+
"@datafrog-io/n2n-nexus",
|
|
98
|
+
"--id", "Master-AI",
|
|
99
|
+
"--moderator",
|
|
100
|
+
"--root", "D:/DevSpace/Nexus_Storage"
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### 普通 AI
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"mcpServers": {
|
|
111
|
+
"n2n-nexus": {
|
|
112
|
+
"command": "npx",
|
|
113
|
+
"args": [
|
|
114
|
+
"-y",
|
|
115
|
+
"@datafrog-io/n2n-nexus",
|
|
116
|
+
"--id", "Assistant-AI",
|
|
117
|
+
"--root", "D:/DevSpace/Nexus_Storage"
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 命令行参数
|
|
125
|
+
| 参数 | 说明 | 默认值 |
|
|
126
|
+
|------|------|--------|
|
|
127
|
+
| `--id` | 当前 AI 助手的实例标识符 | `Assistant` |
|
|
128
|
+
| `--moderator` | 授予此实例管理员权限 | `false` |
|
|
129
|
+
| `--root` | 本地数据存储路径 | `./storage` |
|
|
130
|
+
|
|
131
|
+
> **注意:** 仅带有 `--moderator` 标志的实例可使用管理员工具(如 `moderator_maintenance` 和 `moderator_delete_project`)。
|
|
132
|
+
|
|
133
|
+
### 本地开发
|
|
134
|
+
```bash
|
|
135
|
+
git clone https://github.com/n2ns/n2n-nexus.git
|
|
136
|
+
cd n2n-nexus
|
|
137
|
+
npm install
|
|
138
|
+
npm run build
|
|
139
|
+
npm start -- --id Master-AI --root ./my-storage
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
© 2025 datafrog.io. Built for Local-Only AI Workflows.
|
package/build/index.js
CHANGED
|
@@ -15,7 +15,7 @@ class NexusServer {
|
|
|
15
15
|
server;
|
|
16
16
|
currentProject = null;
|
|
17
17
|
constructor() {
|
|
18
|
-
this.server = new Server({ name: "n2n-nexus", version: "0.1.
|
|
18
|
+
this.server = new Server({ name: "n2n-nexus", version: "0.1.8" }, { capabilities: { resources: {}, tools: {}, prompts: {} } });
|
|
19
19
|
this.setupHandlers();
|
|
20
20
|
}
|
|
21
21
|
/**
|
package/build/resources/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "fs";
|
|
2
2
|
import { CONFIG } from "../config.js";
|
|
3
3
|
import { StorageManager } from "../storage/index.js";
|
|
4
|
+
import { UnifiedMeetingStore } from "../storage/store.js";
|
|
4
5
|
/**
|
|
5
6
|
* Resource content providers for MCP ReadResourceRequestSchema
|
|
6
7
|
*/
|
|
@@ -27,6 +28,30 @@ export async function getResourceContent(uri, currentProject) {
|
|
|
27
28
|
};
|
|
28
29
|
return { mimeType: "application/json", text: JSON.stringify(info, null, 2) };
|
|
29
30
|
}
|
|
31
|
+
if (uri === "mcp://nexus/status") {
|
|
32
|
+
const state = await UnifiedMeetingStore.getState();
|
|
33
|
+
const storageInfo = await UnifiedMeetingStore.getStorageInfo();
|
|
34
|
+
const status = {
|
|
35
|
+
status: "online",
|
|
36
|
+
version: "0.1.8",
|
|
37
|
+
...storageInfo,
|
|
38
|
+
active_meetings_count: state.activeMeetings.length,
|
|
39
|
+
default_meeting: state.defaultMeetingId
|
|
40
|
+
};
|
|
41
|
+
return { mimeType: "application/json", text: JSON.stringify(status, null, 2) };
|
|
42
|
+
}
|
|
43
|
+
if (uri === "mcp://nexus/active-meeting") {
|
|
44
|
+
const active = await UnifiedMeetingStore.getActiveMeeting();
|
|
45
|
+
if (active)
|
|
46
|
+
return { mimeType: "application/json", text: JSON.stringify(active, null, 2) };
|
|
47
|
+
return { mimeType: "application/json", text: JSON.stringify({ message: "No active meeting" }, null, 2) };
|
|
48
|
+
}
|
|
49
|
+
if (uri.startsWith("mcp://nexus/meetings/")) {
|
|
50
|
+
const meetingId = uri.substring("mcp://nexus/meetings/".length);
|
|
51
|
+
const mtg = await UnifiedMeetingStore.getMeeting(meetingId);
|
|
52
|
+
if (mtg)
|
|
53
|
+
return { mimeType: "application/json", text: JSON.stringify(mtg, null, 2) };
|
|
54
|
+
}
|
|
30
55
|
// Dynamic Project Resources (Handles Namespaces)
|
|
31
56
|
if (uri.startsWith("mcp://hub/projects/")) {
|
|
32
57
|
if (uri.endsWith("/manifest")) {
|
|
@@ -56,6 +81,8 @@ export async function listResources() {
|
|
|
56
81
|
{ uri: "mcp://hub/registry", name: "Global Project Registry", description: "Consolidated index of all local projects." },
|
|
57
82
|
{ uri: "mcp://docs/global-strategy", name: "Master Strategy Blueprint", description: "Top-level cross-project coordination." },
|
|
58
83
|
{ uri: "mcp://nexus/session", name: "Current Session Info", description: "Your identity and role in this Nexus instance." },
|
|
84
|
+
{ uri: "mcp://nexus/status", name: "System Status & Storage Mode", description: "Backend storage mode (sqlite/json) and active meeting counts." },
|
|
85
|
+
{ uri: "mcp://nexus/active-meeting", name: "Current Active Meeting", description: "Full transcript and participants of the current default meeting." },
|
|
59
86
|
...projectIds.map(id => {
|
|
60
87
|
const prefix = id.split("_")[0];
|
|
61
88
|
const typeLabel = {
|
|
@@ -72,7 +99,8 @@ export async function listResources() {
|
|
|
72
99
|
})
|
|
73
100
|
],
|
|
74
101
|
resourceTemplates: [
|
|
75
|
-
{ uriTemplate: "mcp://hub/projects/{projectId}/internal-docs", name: "Internal Project Docs", description: "Markdown-based detailed implementation plans." }
|
|
102
|
+
{ uriTemplate: "mcp://hub/projects/{projectId}/internal-docs", name: "Internal Project Docs", description: "Markdown-based detailed implementation plans." },
|
|
103
|
+
{ uriTemplate: "mcp://nexus/meetings/{meetingId}", name: "Meeting Insights", description: "Full transcript and decisions for a specific meeting." }
|
|
76
104
|
]
|
|
77
105
|
};
|
|
78
106
|
}
|
package/build/storage/index.js
CHANGED
|
@@ -1,41 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { CONFIG } from "../config.js";
|
|
4
|
-
|
|
5
|
-
* Simple mutex lock for preventing concurrent file writes.
|
|
6
|
-
* Ensures that only one write operation can happen at a time.
|
|
7
|
-
*/
|
|
8
|
-
class AsyncMutex {
|
|
9
|
-
locked = false;
|
|
10
|
-
queue = [];
|
|
11
|
-
async acquire() {
|
|
12
|
-
if (!this.locked) {
|
|
13
|
-
this.locked = true;
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
return new Promise((resolve) => {
|
|
17
|
-
this.queue.push(resolve);
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
release() {
|
|
21
|
-
if (this.queue.length > 0) {
|
|
22
|
-
const next = this.queue.shift();
|
|
23
|
-
next?.();
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
this.locked = false;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
async withLock(fn) {
|
|
30
|
-
await this.acquire();
|
|
31
|
-
try {
|
|
32
|
-
return await fn();
|
|
33
|
-
}
|
|
34
|
-
finally {
|
|
35
|
-
this.release();
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
4
|
+
import { AsyncMutex } from "../utils/async-mutex.js";
|
|
39
5
|
export class StorageManager {
|
|
40
6
|
// --- Concurrency Control ---
|
|
41
7
|
static discussionLock = new AsyncMutex();
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { CONFIG } from "../config.js";
|
|
4
|
+
import { AsyncMutex } from "../utils/async-mutex.js";
|
|
5
|
+
/**
|
|
6
|
+
* MeetingStore - Handles all meeting-related storage operations
|
|
7
|
+
*/
|
|
8
|
+
export class MeetingStore {
|
|
9
|
+
static meetingLock = new AsyncMutex();
|
|
10
|
+
static stateLock = new AsyncMutex();
|
|
11
|
+
// --- Path Definitions ---
|
|
12
|
+
static get meetingsDir() { return path.join(CONFIG.rootStorage, "meetings"); }
|
|
13
|
+
static get stateFile() { return path.join(CONFIG.rootStorage, "global", "meeting_state.json"); }
|
|
14
|
+
/**
|
|
15
|
+
* Initialize meeting storage directories
|
|
16
|
+
*/
|
|
17
|
+
static async init() {
|
|
18
|
+
await fs.mkdir(this.meetingsDir, { recursive: true });
|
|
19
|
+
await this.loadStateSafe();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Check if a path exists
|
|
23
|
+
*/
|
|
24
|
+
static async exists(p) {
|
|
25
|
+
try {
|
|
26
|
+
await fs.access(p);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Load meeting state with self-healing
|
|
35
|
+
*/
|
|
36
|
+
static async loadStateSafe() {
|
|
37
|
+
const defaultState = { activeMeetings: [], defaultMeetingId: null };
|
|
38
|
+
try {
|
|
39
|
+
if (!await this.exists(this.stateFile)) {
|
|
40
|
+
await fs.writeFile(this.stateFile, JSON.stringify(defaultState, null, 2), "utf-8");
|
|
41
|
+
return defaultState;
|
|
42
|
+
}
|
|
43
|
+
const content = await fs.readFile(this.stateFile, "utf-8");
|
|
44
|
+
const cleanContent = content.replace(/^\uFEFF/, '').trim();
|
|
45
|
+
if (!cleanContent)
|
|
46
|
+
throw new Error("Empty file");
|
|
47
|
+
return JSON.parse(cleanContent);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
console.warn(`[MeetingStore] Repairing corrupted state file. Error: ${e.message}`);
|
|
51
|
+
await fs.writeFile(this.stateFile, JSON.stringify(defaultState, null, 2), "utf-8");
|
|
52
|
+
return defaultState;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get current meeting state
|
|
57
|
+
*/
|
|
58
|
+
static async getState() {
|
|
59
|
+
return this.loadStateSafe();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Save meeting state
|
|
63
|
+
*/
|
|
64
|
+
static async saveState(state) {
|
|
65
|
+
await fs.writeFile(this.stateFile, JSON.stringify(state, null, 2), "utf-8");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Generate a unique meeting ID
|
|
69
|
+
*/
|
|
70
|
+
static generateMeetingId(topic) {
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const timestamp = now.toISOString().replace(/[-:T]/g, '').substring(0, 14);
|
|
73
|
+
// Create slug from topic, fallback to base64 hash for non-ASCII
|
|
74
|
+
let slug = topic
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
77
|
+
.replace(/^-|-$/g, '')
|
|
78
|
+
.substring(0, 30);
|
|
79
|
+
// If slug is empty (e.g., Chinese topic), use base64 of topic
|
|
80
|
+
if (!slug) {
|
|
81
|
+
slug = Buffer.from(topic).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8).toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
// Add random suffix for uniqueness (prevents collision in same second)
|
|
84
|
+
const suffix = Math.random().toString(36).substring(2, 6);
|
|
85
|
+
return `${timestamp}-${slug || 'meeting'}-${suffix}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get the file path for a meeting
|
|
89
|
+
*/
|
|
90
|
+
static getMeetingPath(id) {
|
|
91
|
+
return path.join(this.meetingsDir, `${id}.json`);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Start a new meeting
|
|
95
|
+
*/
|
|
96
|
+
static async startMeeting(topic, initiator) {
|
|
97
|
+
await this.init();
|
|
98
|
+
return this.stateLock.withLock(async () => {
|
|
99
|
+
const id = this.generateMeetingId(topic);
|
|
100
|
+
const meeting = {
|
|
101
|
+
id,
|
|
102
|
+
topic,
|
|
103
|
+
status: "active",
|
|
104
|
+
startTime: new Date().toISOString(),
|
|
105
|
+
participants: [initiator],
|
|
106
|
+
messages: [],
|
|
107
|
+
decisions: []
|
|
108
|
+
};
|
|
109
|
+
// Save meeting file
|
|
110
|
+
await fs.writeFile(this.getMeetingPath(id), JSON.stringify(meeting, null, 2), "utf-8");
|
|
111
|
+
// Update state
|
|
112
|
+
const state = await this.loadStateSafe();
|
|
113
|
+
state.activeMeetings.push(id);
|
|
114
|
+
state.defaultMeetingId = id;
|
|
115
|
+
await this.saveState(state);
|
|
116
|
+
return meeting;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Get a meeting by ID
|
|
121
|
+
*/
|
|
122
|
+
static async getMeeting(id) {
|
|
123
|
+
const meetingPath = this.getMeetingPath(id);
|
|
124
|
+
if (!await this.exists(meetingPath))
|
|
125
|
+
return null;
|
|
126
|
+
const content = await fs.readFile(meetingPath, "utf-8");
|
|
127
|
+
return JSON.parse(content);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Add a message to a meeting
|
|
131
|
+
*/
|
|
132
|
+
static async addMessage(meetingId, message) {
|
|
133
|
+
await this.meetingLock.withLock(async () => {
|
|
134
|
+
const meeting = await this.getMeeting(meetingId);
|
|
135
|
+
if (!meeting)
|
|
136
|
+
throw new Error(`Meeting '${meetingId}' not found.`);
|
|
137
|
+
if (meeting.status !== "active")
|
|
138
|
+
throw new Error(`Meeting '${meetingId}' is ${meeting.status}, cannot add messages.`);
|
|
139
|
+
// Add message
|
|
140
|
+
meeting.messages.push(message);
|
|
141
|
+
// Track participant
|
|
142
|
+
if (!meeting.participants.includes(message.from)) {
|
|
143
|
+
meeting.participants.push(message.from);
|
|
144
|
+
}
|
|
145
|
+
// Extract decisions
|
|
146
|
+
if (message.category === "DECISION") {
|
|
147
|
+
meeting.decisions.push(message.text);
|
|
148
|
+
}
|
|
149
|
+
await fs.writeFile(this.getMeetingPath(meetingId), JSON.stringify(meeting, null, 2), "utf-8");
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* End a meeting (close it)
|
|
154
|
+
*/
|
|
155
|
+
static async endMeeting(meetingId, summary) {
|
|
156
|
+
return this.stateLock.withLock(async () => {
|
|
157
|
+
const meeting = await this.getMeeting(meetingId);
|
|
158
|
+
if (!meeting)
|
|
159
|
+
throw new Error(`Meeting '${meetingId}' not found.`);
|
|
160
|
+
if (meeting.status !== "active")
|
|
161
|
+
throw new Error(`Meeting '${meetingId}' is already ${meeting.status}.`);
|
|
162
|
+
// Close the meeting
|
|
163
|
+
meeting.status = "closed";
|
|
164
|
+
meeting.endTime = new Date().toISOString();
|
|
165
|
+
if (summary)
|
|
166
|
+
meeting.summary = summary;
|
|
167
|
+
await fs.writeFile(this.getMeetingPath(meetingId), JSON.stringify(meeting, null, 2), "utf-8");
|
|
168
|
+
// Update state - remove from active meetings
|
|
169
|
+
const state = await this.loadStateSafe();
|
|
170
|
+
state.activeMeetings = state.activeMeetings.filter(id => id !== meetingId);
|
|
171
|
+
state.defaultMeetingId = state.activeMeetings.length > 0
|
|
172
|
+
? state.activeMeetings[state.activeMeetings.length - 1]
|
|
173
|
+
: null;
|
|
174
|
+
await this.saveState(state);
|
|
175
|
+
// Suggest sync targets based on participants (extract project IDs)
|
|
176
|
+
const suggestedSyncTargets = meeting.participants
|
|
177
|
+
.map(p => p.split('@')[1])
|
|
178
|
+
.filter((v, i, a) => v && v !== "Global" && a.indexOf(v) === i);
|
|
179
|
+
return { meeting, suggestedSyncTargets };
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Archive a closed meeting
|
|
184
|
+
*/
|
|
185
|
+
static async archiveMeeting(meetingId) {
|
|
186
|
+
const meeting = await this.getMeeting(meetingId);
|
|
187
|
+
if (!meeting)
|
|
188
|
+
throw new Error(`Meeting '${meetingId}' not found.`);
|
|
189
|
+
if (meeting.status === "active")
|
|
190
|
+
throw new Error(`Meeting '${meetingId}' is still active. End it first.`);
|
|
191
|
+
meeting.status = "archived";
|
|
192
|
+
await fs.writeFile(this.getMeetingPath(meetingId), JSON.stringify(meeting, null, 2), "utf-8");
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* List all meetings with optional status filter
|
|
196
|
+
*/
|
|
197
|
+
static async listMeetings(status) {
|
|
198
|
+
await this.init();
|
|
199
|
+
const files = await fs.readdir(this.meetingsDir);
|
|
200
|
+
const meetings = [];
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
if (!file.endsWith('.json'))
|
|
203
|
+
continue;
|
|
204
|
+
const id = file.replace('.json', '');
|
|
205
|
+
const meeting = await this.getMeeting(id);
|
|
206
|
+
if (!meeting)
|
|
207
|
+
continue;
|
|
208
|
+
if (status && meeting.status !== status)
|
|
209
|
+
continue;
|
|
210
|
+
meetings.push({
|
|
211
|
+
id: meeting.id,
|
|
212
|
+
topic: meeting.topic,
|
|
213
|
+
status: meeting.status,
|
|
214
|
+
startTime: meeting.startTime,
|
|
215
|
+
participantCount: meeting.participants.length
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// Sort by startTime descending
|
|
219
|
+
return meetings.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Get the current active meeting (for auto-routing)
|
|
223
|
+
*/
|
|
224
|
+
static async getActiveMeeting() {
|
|
225
|
+
const state = await this.getState();
|
|
226
|
+
if (!state.defaultMeetingId)
|
|
227
|
+
return null;
|
|
228
|
+
return this.getMeeting(state.defaultMeetingId);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Get recent messages from the active meeting
|
|
232
|
+
*/
|
|
233
|
+
static async getRecentMessages(count = 10, meetingId) {
|
|
234
|
+
const targetId = meetingId || (await this.getState()).defaultMeetingId;
|
|
235
|
+
if (!targetId)
|
|
236
|
+
return [];
|
|
237
|
+
const meeting = await this.getMeeting(targetId);
|
|
238
|
+
if (!meeting)
|
|
239
|
+
return [];
|
|
240
|
+
return meeting.messages.slice(-count);
|
|
241
|
+
}
|
|
242
|
+
}
|