n2n-nexus 0.4.2 → 0.6.0
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 +87 -0
- package/README.md +74 -117
- package/assets/n2n-nexus-banner.jpg +0 -0
- package/assets/n2n-nexus-icon.png +0 -0
- package/assets/n2n-nexus-logo.png +0 -0
- package/build/client/nexus-client.js +30 -14
- package/build/config/cli.js +0 -3
- package/build/config/index.js +0 -10
- package/build/constants.js +7 -11
- package/build/daemon/index.js +35 -19
- package/build/daemon/rate-limit.js +62 -0
- package/build/daemon/server.js +349 -542
- package/build/daemon/tool-outputs.js +155 -0
- package/build/daemon/tools.js +271 -0
- package/build/index.js +1 -1
- package/build/server/instance-id.js +19 -0
- package/build/server/nexus.js +152 -43
- package/build/storage/docs.js +14 -7
- package/build/storage/events.js +31 -0
- package/build/storage/fs-utils.js +42 -0
- package/build/storage/global-messages.js +44 -0
- package/build/storage/index.js +44 -41
- package/build/storage/migrations.js +79 -0
- package/build/storage/paths.js +0 -3
- package/build/storage/projects.js +4 -5
- package/build/storage/registry.js +67 -25
- package/build/storage/sqlite-meeting.js +35 -23
- package/build/storage/sqlite.js +41 -24
- package/build/storage/tasks.js +29 -103
- package/build/types.js +1 -1
- package/docs/ARCHITECTURE.md +29 -20
- package/docs/ARCHITECTURE_zh.md +29 -20
- package/docs/ASSISTANT_GUIDE.md +70 -67
- package/docs/README_zh.md +25 -15
- package/llms.txt +11 -1
- package/package.json +24 -14
- package/build/storage/logs.js +0 -60
- package/build/storage/meetings.js +0 -276
- package/build/storage/store.js +0 -153
package/build/daemon/server.js
CHANGED
|
@@ -1,364 +1,63 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import net from "node:net";
|
|
2
3
|
import { URL } from "node:url";
|
|
3
4
|
import { StorageManager } from "../storage/index.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { SqliteMeetingStore } from "../storage/sqlite-meeting.js";
|
|
6
|
+
import { GlobalMessageStore } from "../storage/global-messages.js";
|
|
7
|
+
import { EventStore } from "../storage/events.js";
|
|
8
|
+
import { runMigrations } from "../storage/migrations.js";
|
|
9
|
+
import { createTask, updateTask, getTask, listTasks, cancelTask } from "../storage/tasks.js";
|
|
10
|
+
import { MAX_BODY_BYTES } from "../constants.js";
|
|
11
|
+
import { ADDRESS_LIMIT_FACTOR, RateLimiter, rateLimitFromEnv } from "./rate-limit.js";
|
|
12
|
+
import { ResultContractError, finalizeResult } from "./tool-outputs.js";
|
|
13
|
+
import { TOOL_DEFINITIONS, isToolName, parseToolArgs, taskUpdateSchema, ToolValidationError } from "./tools.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Storage bootstrap
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
6
18
|
async function initializeStorage() {
|
|
7
19
|
await StorageManager.init();
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return {
|
|
11
|
-
ready: true,
|
|
12
|
-
storageMode: info.storage_mode,
|
|
13
|
-
isDegraded: info.is_degraded
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
const sessions = new Map();
|
|
17
|
-
function getSession(instanceId) {
|
|
18
|
-
if (!sessions.has(instanceId))
|
|
19
|
-
sessions.set(instanceId, { currentProject: null });
|
|
20
|
-
return sessions.get(instanceId);
|
|
20
|
+
SqliteMeetingStore.init();
|
|
21
|
+
await runMigrations();
|
|
21
22
|
}
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
|
-
// Tool definitions (served via GET /api/tools)
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
const TOOL_DEFINITIONS = [
|
|
26
|
-
// Session
|
|
27
|
-
{
|
|
28
|
-
name: "register_session_context",
|
|
29
|
-
description: "Declare active project. Format: [prefix]_[name] (e.g. 'web_example.com', 'mcp_nexus').",
|
|
30
|
-
inputSchema: {
|
|
31
|
-
type: "object",
|
|
32
|
-
properties: { projectId: { type: "string", description: "Project ID with prefix" } },
|
|
33
|
-
required: ["projectId"]
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
// Project assets
|
|
37
|
-
{
|
|
38
|
-
name: "sync_project_assets",
|
|
39
|
-
description: "[ASYNC] Sync full project manifest + internal docs. Returns taskId.",
|
|
40
|
-
inputSchema: {
|
|
41
|
-
type: "object",
|
|
42
|
-
properties: {
|
|
43
|
-
manifest: {
|
|
44
|
-
type: "object",
|
|
45
|
-
description: "Project metadata",
|
|
46
|
-
properties: {
|
|
47
|
-
id: { type: "string" }, name: { type: "string" },
|
|
48
|
-
description: { type: "string" },
|
|
49
|
-
techStack: { type: "array", items: { type: "string" } },
|
|
50
|
-
relations: { type: "array", items: { type: "object" } },
|
|
51
|
-
repositoryUrl: { type: "string" }, localPath: { type: "string" },
|
|
52
|
-
endpoints: { type: "array", items: { type: "object" } },
|
|
53
|
-
apiSpec: { type: "array", items: { type: "object" } }
|
|
54
|
-
},
|
|
55
|
-
required: ["id", "name", "description", "techStack", "relations", "repositoryUrl", "localPath", "endpoints", "apiSpec"]
|
|
56
|
-
},
|
|
57
|
-
internalDocs: { type: "string", description: "Markdown implementation guide" }
|
|
58
|
-
},
|
|
59
|
-
required: ["manifest", "internalDocs"]
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
name: "upload_project_asset",
|
|
64
|
-
description: "Upload binary file (base64) to active project's asset folder.",
|
|
65
|
-
inputSchema: {
|
|
66
|
-
type: "object",
|
|
67
|
-
properties: {
|
|
68
|
-
fileName: { type: "string", description: "Safe filename (no path traversal)" },
|
|
69
|
-
base64Content: { type: "string" }
|
|
70
|
-
},
|
|
71
|
-
required: ["fileName", "base64Content"]
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
name: "update_project",
|
|
76
|
-
description: "Patch project manifest fields (partial update).",
|
|
77
|
-
inputSchema: {
|
|
78
|
-
type: "object",
|
|
79
|
-
properties: {
|
|
80
|
-
projectId: { type: "string" },
|
|
81
|
-
patch: { type: "object", description: "Fields to update" }
|
|
82
|
-
},
|
|
83
|
-
required: ["projectId", "patch"]
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
name: "rename_project",
|
|
88
|
-
description: "[ASYNC] Rename project ID with cascading relation updates. Returns taskId.",
|
|
89
|
-
inputSchema: {
|
|
90
|
-
type: "object",
|
|
91
|
-
properties: { oldId: { type: "string" }, newId: { type: "string" } },
|
|
92
|
-
required: ["oldId", "newId"]
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
// Global collaboration
|
|
96
|
-
{
|
|
97
|
-
name: "search_projects",
|
|
98
|
-
description: "Search project registry by name or description.",
|
|
99
|
-
inputSchema: {
|
|
100
|
-
type: "object",
|
|
101
|
-
properties: {
|
|
102
|
-
query: { type: "string" },
|
|
103
|
-
limit: { type: "integer", default: 10 }
|
|
104
|
-
},
|
|
105
|
-
required: ["query"]
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
name: "get_global_topology",
|
|
110
|
-
description: "Default: project list + stats. With projectId: detailed subgraph.",
|
|
111
|
-
inputSchema: {
|
|
112
|
-
type: "object",
|
|
113
|
-
properties: { projectId: { type: "string", description: "Focus on specific project (optional)" } }
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
name: "send_message",
|
|
118
|
-
description: "Post message to active meeting or global chat.",
|
|
119
|
-
inputSchema: {
|
|
120
|
-
type: "object",
|
|
121
|
-
properties: {
|
|
122
|
-
message: { type: "string" },
|
|
123
|
-
category: { type: "string", enum: ["MEETING_START", "PROPOSAL", "DECISION", "UPDATE", "CHAT"] }
|
|
124
|
-
},
|
|
125
|
-
required: ["message"]
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
name: "read_messages",
|
|
130
|
-
description: "Read unread messages (auto-incremental per instance).",
|
|
131
|
-
inputSchema: {
|
|
132
|
-
type: "object",
|
|
133
|
-
properties: {
|
|
134
|
-
count: { type: "integer", default: 10 },
|
|
135
|
-
meetingId: { type: "string" }
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
name: "update_global_strategy",
|
|
141
|
-
description: "Overwrite master strategy document.",
|
|
142
|
-
inputSchema: {
|
|
143
|
-
type: "object",
|
|
144
|
-
properties: { content: { type: "string" } },
|
|
145
|
-
required: ["content"]
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
name: "sync_global_doc",
|
|
150
|
-
description: "Create/update a global shared document.",
|
|
151
|
-
inputSchema: {
|
|
152
|
-
type: "object",
|
|
153
|
-
properties: {
|
|
154
|
-
docId: { type: "string" },
|
|
155
|
-
title: { type: "string" },
|
|
156
|
-
content: { type: "string" }
|
|
157
|
-
},
|
|
158
|
-
required: ["docId", "title", "content"]
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
// Meeting management
|
|
162
|
-
{
|
|
163
|
-
name: "start_meeting",
|
|
164
|
-
description: "Start new meeting session. Returns meeting ID.",
|
|
165
|
-
inputSchema: {
|
|
166
|
-
type: "object",
|
|
167
|
-
properties: { topic: { type: "string" } },
|
|
168
|
-
required: ["topic"]
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
name: "end_meeting",
|
|
173
|
-
description: "End active meeting. Locks history.",
|
|
174
|
-
inputSchema: {
|
|
175
|
-
type: "object",
|
|
176
|
-
properties: {
|
|
177
|
-
meetingId: { type: "string" },
|
|
178
|
-
summary: { type: "string" }
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
name: "archive_meeting",
|
|
184
|
-
description: "Archive closed meeting. Read-only after.",
|
|
185
|
-
inputSchema: {
|
|
186
|
-
type: "object",
|
|
187
|
-
properties: { meetingId: { type: "string" } },
|
|
188
|
-
required: ["meetingId"]
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
name: "reopen_meeting",
|
|
193
|
-
description: "Reopen closed/archived meeting.",
|
|
194
|
-
inputSchema: {
|
|
195
|
-
type: "object",
|
|
196
|
-
properties: { meetingId: { type: "string" } },
|
|
197
|
-
required: ["meetingId"]
|
|
198
|
-
}
|
|
199
|
-
},
|
|
200
|
-
// Task management
|
|
201
|
-
{
|
|
202
|
-
name: "create_task",
|
|
203
|
-
description: "[ASYNC] Create background task. Returns taskId for polling.",
|
|
204
|
-
inputSchema: {
|
|
205
|
-
type: "object",
|
|
206
|
-
properties: {
|
|
207
|
-
source_meeting_id: { type: "string", description: "Link to meeting for traceability" },
|
|
208
|
-
metadata: { type: "object" },
|
|
209
|
-
ttl: { type: "integer", description: "TTL in milliseconds" }
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
name: "get_task",
|
|
215
|
-
description: "Get task status and progress by ID.",
|
|
216
|
-
inputSchema: {
|
|
217
|
-
type: "object",
|
|
218
|
-
properties: { taskId: { type: "string" } },
|
|
219
|
-
required: ["taskId"]
|
|
220
|
-
}
|
|
221
|
-
},
|
|
222
|
-
{
|
|
223
|
-
name: "list_tasks",
|
|
224
|
-
description: "List tasks with optional status filter.",
|
|
225
|
-
inputSchema: {
|
|
226
|
-
type: "object",
|
|
227
|
-
properties: {
|
|
228
|
-
status: { type: "string", enum: ["pending", "running", "completed", "failed", "cancelled"] },
|
|
229
|
-
limit: { type: "integer", default: 50 }
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
name: "cancel_task",
|
|
235
|
-
description: "Cancel pending/running task.",
|
|
236
|
-
inputSchema: {
|
|
237
|
-
type: "object",
|
|
238
|
-
properties: { taskId: { type: "string" } },
|
|
239
|
-
required: ["taskId"]
|
|
240
|
-
}
|
|
241
|
-
},
|
|
242
|
-
// Maintenance
|
|
243
|
-
{
|
|
244
|
-
name: "host_maintenance",
|
|
245
|
-
description: "Manage logs: 'prune' oldest N or 'clear' all.",
|
|
246
|
-
inputSchema: {
|
|
247
|
-
type: "object",
|
|
248
|
-
properties: {
|
|
249
|
-
action: { type: "string", enum: ["prune", "clear"] },
|
|
250
|
-
count: { type: "integer", minimum: 0 }
|
|
251
|
-
},
|
|
252
|
-
required: ["action", "count"]
|
|
253
|
-
}
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
name: "host_delete_project",
|
|
257
|
-
description: "[ASYNC] Delete project. Irreversible. Returns taskId.",
|
|
258
|
-
inputSchema: {
|
|
259
|
-
type: "object",
|
|
260
|
-
properties: { projectId: { type: "string" } },
|
|
261
|
-
required: ["projectId"]
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
];
|
|
265
|
-
// ---------------------------------------------------------------------------
|
|
266
24
|
// Tool handlers
|
|
267
25
|
// ---------------------------------------------------------------------------
|
|
268
|
-
async function toolRegisterSessionContext(args, instanceId) {
|
|
269
|
-
if (!args.projectId)
|
|
270
|
-
throw new Error("projectId is required.");
|
|
271
|
-
const manifest = await StorageManager.getProjectManifest(args.projectId);
|
|
272
|
-
if (!manifest) {
|
|
273
|
-
return { ok: false, message: `Project '${args.projectId}' not found in registry.` };
|
|
274
|
-
}
|
|
275
|
-
getSession(instanceId).currentProject = args.projectId;
|
|
276
|
-
await StorageManager.addGlobalLog("SYSTEM", `Session Context set to Project: ${args.projectId} (instance: ${instanceId})`);
|
|
277
|
-
return { ok: true, message: `Session context registered for project: ${args.projectId}.` };
|
|
278
|
-
}
|
|
279
26
|
async function toolSyncProjectAssets(args, instanceId) {
|
|
280
|
-
const m = args.manifest;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
metadata: { operation: "sync_project_assets", projectId: m.id, initiator: instanceId }
|
|
285
|
-
});
|
|
286
|
-
setImmediate(async () => {
|
|
287
|
-
try {
|
|
288
|
-
updateTask(task.id, { status: "running", progress: 0.1 });
|
|
289
|
-
await StorageManager.saveProjectManifest(m);
|
|
290
|
-
updateTask(task.id, { progress: 0.5 });
|
|
291
|
-
await StorageManager.saveProjectDocs(m.id, args.internalDocs);
|
|
292
|
-
updateTask(task.id, { progress: 0.9 });
|
|
293
|
-
await StorageManager.addGlobalLog("SYSTEM", `[${instanceId}@${m.id}] Asset Sync completed.`);
|
|
294
|
-
updateTask(task.id, { status: "completed", progress: 1.0, result_uri: `nexus://projects/${m.id}/manifest` });
|
|
295
|
-
}
|
|
296
|
-
catch (e) {
|
|
297
|
-
updateTask(task.id, { status: "failed", error_message: String(e) });
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
return { task_id: task.id, status: "pending", message: "Sync task created. Use get_task to poll for completion." };
|
|
27
|
+
const m = { ...args.manifest, lastUpdated: args.manifest.lastUpdated ?? new Date().toISOString() };
|
|
28
|
+
await StorageManager.saveProject(m, args.internalDocs);
|
|
29
|
+
EventStore.record(instanceId, "project.sync", m.id);
|
|
30
|
+
return { ok: true, projectId: m.id, message: `Project '${m.id}' manifest and docs synced.` };
|
|
301
31
|
}
|
|
302
32
|
async function toolUploadAsset(args, instanceId) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
throw new Error("No active project. Call register_session_context first.");
|
|
306
|
-
if (!args.fileName || !args.base64Content)
|
|
307
|
-
throw new Error("fileName and base64Content are required.");
|
|
33
|
+
if (!await StorageManager.getProjectManifest(args.projectId))
|
|
34
|
+
throw new Error(`Project '${args.projectId}' not found.`);
|
|
308
35
|
const sanitized = args.fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
309
36
|
const buf = Buffer.from(args.base64Content, "base64");
|
|
310
|
-
await StorageManager.saveAsset(
|
|
311
|
-
|
|
37
|
+
await StorageManager.saveAsset(args.projectId, sanitized, buf);
|
|
38
|
+
EventStore.record(instanceId, "project.asset", args.projectId, { fileName: sanitized, bytes: buf.length });
|
|
39
|
+
return { ok: true, projectId: args.projectId, fileName: sanitized, message: `Asset '${sanitized}' saved to project '${args.projectId}'.` };
|
|
312
40
|
}
|
|
313
41
|
async function toolUpdateProject(args, instanceId) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
throw new Error("Cannot change 'id' via patch. Use rename_project instead.");
|
|
318
|
-
const task = createTask({
|
|
319
|
-
metadata: { operation: "update_project", projectId: args.projectId, initiator: instanceId }
|
|
320
|
-
});
|
|
321
|
-
setImmediate(async () => {
|
|
322
|
-
try {
|
|
323
|
-
updateTask(task.id, { status: "running", progress: 0.2 });
|
|
324
|
-
await StorageManager.patchProjectManifest(args.projectId, args.patch);
|
|
325
|
-
updateTask(task.id, { status: "completed", progress: 1.0, result_uri: `nexus://projects/${args.projectId}/manifest` });
|
|
326
|
-
}
|
|
327
|
-
catch (e) {
|
|
328
|
-
updateTask(task.id, { status: "failed", error_message: String(e) });
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
return { task_id: task.id, status: "pending", message: "Update task created." };
|
|
42
|
+
await StorageManager.patchProjectManifest(args.projectId, args.patch);
|
|
43
|
+
EventStore.record(instanceId, "project.update", args.projectId, { fields: Object.keys(args.patch) });
|
|
44
|
+
return { ok: true, projectId: args.projectId, message: `Project '${args.projectId}' updated.` };
|
|
332
45
|
}
|
|
333
46
|
async function toolRenameProject(args, instanceId) {
|
|
334
|
-
if (
|
|
335
|
-
throw new Error("oldId and newId are
|
|
47
|
+
if (args.oldId === args.newId)
|
|
48
|
+
throw new Error("oldId and newId are identical.");
|
|
336
49
|
const exists = await StorageManager.getProjectManifest(args.oldId);
|
|
337
50
|
if (!exists)
|
|
338
51
|
throw new Error(`Project '${args.oldId}' not found.`);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
setImmediate(async () => {
|
|
343
|
-
try {
|
|
344
|
-
updateTask(task.id, { status: "running", progress: 0.2 });
|
|
345
|
-
const count = await StorageManager.renameProject(args.oldId, args.newId);
|
|
346
|
-
await StorageManager.addGlobalLog("SYSTEM", `Project renamed '${args.oldId}' → '${args.newId}' (${count} cascading updates).`);
|
|
347
|
-
updateTask(task.id, { status: "completed", progress: 1.0, result_uri: `nexus://projects/${args.newId}/manifest` });
|
|
348
|
-
}
|
|
349
|
-
catch (e) {
|
|
350
|
-
updateTask(task.id, { status: "failed", error_message: String(e) });
|
|
351
|
-
}
|
|
352
|
-
});
|
|
353
|
-
return { task_id: task.id, status: "pending", message: "Rename task created." };
|
|
52
|
+
await StorageManager.renameProject(args.oldId, args.newId);
|
|
53
|
+
EventStore.record(instanceId, "project.rename", args.newId, { oldId: args.oldId });
|
|
54
|
+
return { ok: true, oldId: args.oldId, newId: args.newId, message: `Project renamed '${args.oldId}' → '${args.newId}'.` };
|
|
354
55
|
}
|
|
355
56
|
async function toolSearchProjects(args) {
|
|
356
|
-
if (!args.query)
|
|
357
|
-
throw new Error("query is required.");
|
|
358
57
|
const registry = await StorageManager.listRegistry();
|
|
359
58
|
const projects = registry.projects;
|
|
360
59
|
const q = args.query.toLowerCase();
|
|
361
|
-
const limit = args.limit
|
|
60
|
+
const limit = args.limit;
|
|
362
61
|
const results = Object.entries(projects)
|
|
363
62
|
.filter(([id, p]) => id.toLowerCase().includes(q) ||
|
|
364
63
|
(p.name && p.name.toLowerCase().includes(q)) ||
|
|
@@ -370,12 +69,16 @@ async function toolSearchProjects(args) {
|
|
|
370
69
|
async function toolGetGlobalTopology(args) {
|
|
371
70
|
return StorageManager.calculateTopology(args.projectId);
|
|
372
71
|
}
|
|
72
|
+
async function toolGetProject(args) {
|
|
73
|
+
const manifest = await StorageManager.getProjectManifest(args.projectId);
|
|
74
|
+
if (!manifest)
|
|
75
|
+
throw new Error(`Project '${args.projectId}' not found.`);
|
|
76
|
+
return args.includeDocs
|
|
77
|
+
? { manifest, internalDocs: await StorageManager.getProjectDocs(args.projectId) }
|
|
78
|
+
: { manifest };
|
|
79
|
+
}
|
|
373
80
|
async function toolSendMessage(args, instanceId) {
|
|
374
|
-
|
|
375
|
-
throw new Error("message is required.");
|
|
376
|
-
const session = getSession(instanceId);
|
|
377
|
-
const activeMeeting = await UnifiedMeetingStore.getActiveMeeting();
|
|
378
|
-
const meetingId = (activeMeeting && session.currentProject) ? activeMeeting.id : null;
|
|
81
|
+
const meetingId = args.meetingId ?? SqliteMeetingStore.getActiveMeetingId();
|
|
379
82
|
if (meetingId) {
|
|
380
83
|
const msg = {
|
|
381
84
|
from: instanceId,
|
|
@@ -383,72 +86,87 @@ async function toolSendMessage(args, instanceId) {
|
|
|
383
86
|
category: args.category,
|
|
384
87
|
timestamp: new Date().toISOString()
|
|
385
88
|
};
|
|
386
|
-
|
|
89
|
+
SqliteMeetingStore.addMessage(meetingId, msg);
|
|
387
90
|
return { ok: true, sentTo: "meeting", meetingId, message: `Message sent to meeting ${meetingId}.` };
|
|
388
91
|
}
|
|
389
|
-
|
|
390
|
-
return { ok: true, sentTo: "global", message: "Message sent to global chat." };
|
|
92
|
+
const saved = GlobalMessageStore.addMessage(instanceId, args.message, args.category);
|
|
93
|
+
return { ok: true, sentTo: "global", messageId: saved.id, message: "Message sent to global chat." };
|
|
391
94
|
}
|
|
392
95
|
async function toolReadMessages(args, instanceId) {
|
|
393
|
-
const count = args.count
|
|
96
|
+
const count = args.count;
|
|
394
97
|
if (args.meetingId) {
|
|
395
|
-
const meeting =
|
|
98
|
+
const meeting = SqliteMeetingStore.getMeeting(args.meetingId);
|
|
396
99
|
if (!meeting)
|
|
397
100
|
throw new Error(`Meeting '${args.meetingId}' not found.`);
|
|
398
|
-
const messages =
|
|
101
|
+
const messages = SqliteMeetingStore.getRecentMessages(count, args.meetingId, instanceId);
|
|
399
102
|
return { source: "meeting", meetingId: args.meetingId, messages, count: messages.length };
|
|
400
103
|
}
|
|
401
|
-
const
|
|
402
|
-
return { source: "global", messages
|
|
104
|
+
const messages = GlobalMessageStore.readUnread(instanceId, count);
|
|
105
|
+
return { source: "global", messages, count: messages.length };
|
|
403
106
|
}
|
|
404
107
|
async function toolUpdateGlobalStrategy(args, instanceId) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
await StorageManager.saveGlobalDoc("strategy", "Global Collaboration Strategy", args.content, instanceId);
|
|
108
|
+
await StorageManager.saveGlobalBlueprint(args.content);
|
|
109
|
+
EventStore.record(instanceId, "strategy.update");
|
|
408
110
|
return { ok: true, message: "Global strategy updated." };
|
|
409
111
|
}
|
|
112
|
+
async function toolGetGlobalStrategy() {
|
|
113
|
+
return { content: await StorageManager.getGlobalBlueprint() };
|
|
114
|
+
}
|
|
115
|
+
async function toolListGlobalDocs() {
|
|
116
|
+
const index = await StorageManager.listGlobalDocs();
|
|
117
|
+
const docs = Object.entries(index)
|
|
118
|
+
.map(([docId, entry]) => ({ docId, ...entry }))
|
|
119
|
+
.sort((a, b) => b.lastUpdated.localeCompare(a.lastUpdated));
|
|
120
|
+
return { count: docs.length, docs };
|
|
121
|
+
}
|
|
122
|
+
async function toolGetGlobalDoc(args) {
|
|
123
|
+
const entry = (await StorageManager.listGlobalDocs())[args.docId];
|
|
124
|
+
if (!entry)
|
|
125
|
+
throw new Error(`Document '${args.docId}' not found.`);
|
|
126
|
+
return { docId: args.docId, ...entry, content: await StorageManager.getGlobalDoc(args.docId) };
|
|
127
|
+
}
|
|
410
128
|
async function toolSyncGlobalDoc(args, instanceId) {
|
|
411
|
-
if (!args.docId || !args.title || !args.content)
|
|
412
|
-
throw new Error("docId, title, and content are required.");
|
|
413
129
|
await StorageManager.saveGlobalDoc(args.docId, args.title, args.content, instanceId);
|
|
130
|
+
EventStore.record(instanceId, "doc.sync", args.docId, { title: args.title });
|
|
414
131
|
return { ok: true, message: `Global document '${args.title}' (${args.docId}) synchronized.` };
|
|
415
132
|
}
|
|
133
|
+
async function toolListMeetings(args) {
|
|
134
|
+
const meetings = SqliteMeetingStore.listMeetings(args.status, args.limit);
|
|
135
|
+
return { count: meetings.length, activeMeetingId: SqliteMeetingStore.getActiveMeetingId(), meetings };
|
|
136
|
+
}
|
|
416
137
|
async function toolStartMeeting(args, instanceId) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const meeting = await UnifiedMeetingStore.startMeeting(args.topic, instanceId);
|
|
138
|
+
const meeting = SqliteMeetingStore.startMeeting(args.topic, instanceId);
|
|
139
|
+
EventStore.record(instanceId, "meeting.start", meeting.id, { topic: meeting.topic });
|
|
420
140
|
return { ok: true, meetingId: meeting.id, topic: meeting.topic, status: meeting.status };
|
|
421
141
|
}
|
|
422
142
|
async function toolEndMeeting(args, instanceId) {
|
|
423
|
-
|
|
424
|
-
if (!id) {
|
|
425
|
-
const active = await UnifiedMeetingStore.getActiveMeeting();
|
|
426
|
-
if (active)
|
|
427
|
-
id = active.id;
|
|
428
|
-
}
|
|
143
|
+
const id = args.meetingId ?? SqliteMeetingStore.getActiveMeetingId();
|
|
429
144
|
if (!id)
|
|
430
145
|
throw new Error("No active meeting found to end.");
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
return { ok: true, meetingId: id, status: "closed", summary: result.meeting.summary };
|
|
146
|
+
const meeting = SqliteMeetingStore.endMeeting(id, args.summary, instanceId);
|
|
147
|
+
EventStore.record(instanceId, "meeting.end", id, { topic: meeting.topic, summary: args.summary });
|
|
148
|
+
return { ok: true, meetingId: id, status: "closed", summary: meeting.summary };
|
|
436
149
|
}
|
|
437
150
|
async function toolArchiveMeeting(args, instanceId) {
|
|
438
|
-
const meeting =
|
|
151
|
+
const meeting = SqliteMeetingStore.getMeeting(args.meetingId);
|
|
439
152
|
if (!meeting)
|
|
440
153
|
throw new Error(`Meeting '${args.meetingId}' not found.`);
|
|
441
|
-
|
|
154
|
+
SqliteMeetingStore.archiveMeeting(args.meetingId, instanceId);
|
|
155
|
+
EventStore.record(instanceId, "meeting.archive", args.meetingId);
|
|
442
156
|
return { ok: true, meetingId: args.meetingId, status: "archived" };
|
|
443
157
|
}
|
|
444
158
|
async function toolReopenMeeting(args, instanceId) {
|
|
445
|
-
const meeting =
|
|
159
|
+
const meeting = SqliteMeetingStore.getMeeting(args.meetingId);
|
|
446
160
|
if (!meeting)
|
|
447
161
|
throw new Error(`Meeting '${args.meetingId}' not found.`);
|
|
448
|
-
|
|
162
|
+
SqliteMeetingStore.reopenMeeting(args.meetingId, instanceId);
|
|
163
|
+
EventStore.record(instanceId, "meeting.reopen", args.meetingId);
|
|
449
164
|
return { ok: true, meetingId: args.meetingId, status: "active" };
|
|
450
165
|
}
|
|
451
166
|
async function toolCreateTask(args) {
|
|
167
|
+
if (args.source_meeting_id && !SqliteMeetingStore.getMeeting(args.source_meeting_id)) {
|
|
168
|
+
throw new Error(`Meeting '${args.source_meeting_id}' not found.`);
|
|
169
|
+
}
|
|
452
170
|
const task = createTask({ source_meeting_id: args.source_meeting_id, metadata: args.metadata, ttl: args.ttl });
|
|
453
171
|
return { task_id: task.id, status: task.status, message: "Task created.", task };
|
|
454
172
|
}
|
|
@@ -459,7 +177,7 @@ async function toolGetTask(args) {
|
|
|
459
177
|
return task;
|
|
460
178
|
}
|
|
461
179
|
async function toolListTasks(args) {
|
|
462
|
-
const tasks = listTasks(args.status, args.limit
|
|
180
|
+
const tasks = listTasks(args.status, args.limit);
|
|
463
181
|
return { count: tasks.length, tasks };
|
|
464
182
|
}
|
|
465
183
|
async function toolCancelTask(args) {
|
|
@@ -468,112 +186,253 @@ async function toolCancelTask(args) {
|
|
|
468
186
|
throw new Error(`Task '${args.taskId}' not found or cannot be cancelled.`);
|
|
469
187
|
return { ok: true, taskId: args.taskId, message: "Task cancelled." };
|
|
470
188
|
}
|
|
471
|
-
async function toolHostMaintenance(args) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
189
|
+
async function toolHostMaintenance(args, instanceId) {
|
|
190
|
+
const removed = args.action === "clear"
|
|
191
|
+
? GlobalMessageStore.clear()
|
|
192
|
+
: GlobalMessageStore.pruneOldest(args.count);
|
|
193
|
+
EventStore.record(instanceId, `maintenance.${args.action}`, undefined, { removed });
|
|
194
|
+
return { ok: true, removed, remaining: GlobalMessageStore.count(), message: `Removed ${removed} global messages.` };
|
|
195
|
+
}
|
|
196
|
+
async function toolListEvents(args) {
|
|
197
|
+
const events = EventStore.list(args.limit, args.action);
|
|
198
|
+
return { count: events.length, events };
|
|
478
199
|
}
|
|
479
200
|
async function toolHostDeleteProject(args, instanceId) {
|
|
480
201
|
const exists = await StorageManager.getProjectManifest(args.projectId);
|
|
481
202
|
if (!exists)
|
|
482
203
|
throw new Error(`Project '${args.projectId}' not found.`);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
setImmediate(async () => {
|
|
487
|
-
try {
|
|
488
|
-
updateTask(task.id, { status: "running", progress: 0.3 });
|
|
489
|
-
await StorageManager.deleteProject(args.projectId);
|
|
490
|
-
await StorageManager.addGlobalLog("SYSTEM", `Project deleted: '${args.projectId}' by ${instanceId}.`);
|
|
491
|
-
updateTask(task.id, { status: "completed", progress: 1.0 });
|
|
492
|
-
}
|
|
493
|
-
catch (e) {
|
|
494
|
-
updateTask(task.id, { status: "failed", error_message: String(e) });
|
|
495
|
-
}
|
|
496
|
-
});
|
|
497
|
-
return { task_id: task.id, status: "pending", message: "Delete task created." };
|
|
204
|
+
await StorageManager.deleteProject(args.projectId);
|
|
205
|
+
EventStore.record(instanceId, "project.delete", args.projectId);
|
|
206
|
+
return { ok: true, projectId: args.projectId, message: `Project '${args.projectId}' deleted.` };
|
|
498
207
|
}
|
|
499
208
|
// ---------------------------------------------------------------------------
|
|
500
209
|
// Tool dispatcher
|
|
501
210
|
// ---------------------------------------------------------------------------
|
|
502
|
-
async function dispatchTool(name,
|
|
503
|
-
const u = args;
|
|
211
|
+
async function dispatchTool(name, rawArgs, instanceId) {
|
|
504
212
|
switch (name) {
|
|
505
|
-
case "
|
|
506
|
-
case "
|
|
507
|
-
case "
|
|
508
|
-
case "
|
|
509
|
-
case "
|
|
510
|
-
case "
|
|
511
|
-
case "
|
|
512
|
-
case "send_message": return toolSendMessage(
|
|
513
|
-
case "read_messages": return toolReadMessages(
|
|
514
|
-
case "
|
|
515
|
-
case "
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
case "
|
|
519
|
-
case "
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
case "
|
|
523
|
-
case "
|
|
524
|
-
case "
|
|
525
|
-
case "
|
|
526
|
-
|
|
527
|
-
|
|
213
|
+
case "sync_project_assets": return toolSyncProjectAssets(parseToolArgs(name, rawArgs), instanceId);
|
|
214
|
+
case "upload_project_asset": return toolUploadAsset(parseToolArgs(name, rawArgs), instanceId);
|
|
215
|
+
case "update_project": return toolUpdateProject(parseToolArgs(name, rawArgs), instanceId);
|
|
216
|
+
case "rename_project": return toolRenameProject(parseToolArgs(name, rawArgs), instanceId);
|
|
217
|
+
case "search_projects": return toolSearchProjects(parseToolArgs(name, rawArgs));
|
|
218
|
+
case "get_global_topology": return toolGetGlobalTopology(parseToolArgs(name, rawArgs));
|
|
219
|
+
case "get_project": return toolGetProject(parseToolArgs(name, rawArgs));
|
|
220
|
+
case "send_message": return toolSendMessage(parseToolArgs(name, rawArgs), instanceId);
|
|
221
|
+
case "read_messages": return toolReadMessages(parseToolArgs(name, rawArgs), instanceId);
|
|
222
|
+
case "list_events": return toolListEvents(parseToolArgs(name, rawArgs));
|
|
223
|
+
case "get_global_strategy":
|
|
224
|
+
parseToolArgs(name, rawArgs);
|
|
225
|
+
return toolGetGlobalStrategy();
|
|
226
|
+
case "update_global_strategy": return toolUpdateGlobalStrategy(parseToolArgs(name, rawArgs), instanceId);
|
|
227
|
+
case "list_global_docs":
|
|
228
|
+
parseToolArgs(name, rawArgs);
|
|
229
|
+
return toolListGlobalDocs();
|
|
230
|
+
case "get_global_doc": return toolGetGlobalDoc(parseToolArgs(name, rawArgs));
|
|
231
|
+
case "sync_global_doc": return toolSyncGlobalDoc(parseToolArgs(name, rawArgs), instanceId);
|
|
232
|
+
case "list_meetings": return toolListMeetings(parseToolArgs(name, rawArgs));
|
|
233
|
+
case "start_meeting": return toolStartMeeting(parseToolArgs(name, rawArgs), instanceId);
|
|
234
|
+
case "end_meeting": return toolEndMeeting(parseToolArgs(name, rawArgs), instanceId);
|
|
235
|
+
case "archive_meeting": return toolArchiveMeeting(parseToolArgs(name, rawArgs), instanceId);
|
|
236
|
+
case "reopen_meeting": return toolReopenMeeting(parseToolArgs(name, rawArgs), instanceId);
|
|
237
|
+
case "create_task": return toolCreateTask(parseToolArgs(name, rawArgs));
|
|
238
|
+
case "get_task": return toolGetTask(parseToolArgs(name, rawArgs));
|
|
239
|
+
case "list_tasks": return toolListTasks(parseToolArgs(name, rawArgs));
|
|
240
|
+
case "cancel_task": return toolCancelTask(parseToolArgs(name, rawArgs));
|
|
241
|
+
case "host_maintenance": return toolHostMaintenance(parseToolArgs(name, rawArgs), instanceId);
|
|
242
|
+
case "host_delete_project": return toolHostDeleteProject(parseToolArgs(name, rawArgs), instanceId);
|
|
528
243
|
}
|
|
529
244
|
}
|
|
530
245
|
// ---------------------------------------------------------------------------
|
|
531
246
|
// HTTP helpers
|
|
532
247
|
// ---------------------------------------------------------------------------
|
|
533
|
-
function jsonResponse(res, status, body) {
|
|
248
|
+
function jsonResponse(res, status, body, headers = {}) {
|
|
534
249
|
const payload = JSON.stringify(body);
|
|
535
|
-
res.writeHead(status, {
|
|
250
|
+
res.writeHead(status, {
|
|
251
|
+
"Content-Type": "application/json",
|
|
252
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
253
|
+
...(status === 413 ? { Connection: "close" } : {}),
|
|
254
|
+
...headers
|
|
255
|
+
});
|
|
536
256
|
res.end(payload);
|
|
537
257
|
}
|
|
538
|
-
|
|
258
|
+
class HttpError extends Error {
|
|
259
|
+
status;
|
|
260
|
+
headers;
|
|
261
|
+
constructor(status, message, headers = {}) {
|
|
262
|
+
super(message);
|
|
263
|
+
this.status = status;
|
|
264
|
+
this.headers = headers;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function rateLimited(retryAfterSec, scope) {
|
|
268
|
+
return new HttpError(429, `Rate limit exceeded for ${scope}; retry after ${retryAfterSec}s.`, { "Retry-After": String(retryAfterSec) });
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Run a tool for an HTTP request. Validation failures and handler rejections
|
|
272
|
+
* are the caller's fault and surface as 400, not as daemon errors.
|
|
273
|
+
*/
|
|
274
|
+
async function callTool(name, args, instanceId) {
|
|
275
|
+
try {
|
|
276
|
+
return finalizeResult(name, await dispatchTool(name, args, instanceId));
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
if (e instanceof HttpError)
|
|
280
|
+
throw e;
|
|
281
|
+
if (e instanceof ResultContractError) {
|
|
282
|
+
// A daemon-side defect (or unreadable stored data), not the caller's fault.
|
|
283
|
+
console.error("[n2n-nexus daemon]", e.message);
|
|
284
|
+
throw new HttpError(500, e.message);
|
|
285
|
+
}
|
|
286
|
+
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** Instance id recorded for requests that do not name one. */
|
|
290
|
+
const UNKNOWN_INSTANCE = "unknown";
|
|
291
|
+
function actor(body) {
|
|
292
|
+
return typeof body.instanceId === "string" && body.instanceId ? body.instanceId : UNKNOWN_INSTANCE;
|
|
293
|
+
}
|
|
294
|
+
/** Query-string parameters as tool arguments; `numeric` names are converted to numbers. */
|
|
295
|
+
function queryArgs(url, numeric) {
|
|
296
|
+
const args = {};
|
|
297
|
+
for (const [key, value] of url.searchParams) {
|
|
298
|
+
if (key === "instanceId")
|
|
299
|
+
continue;
|
|
300
|
+
args[key] = numeric.includes(key) ? Number(value) : value;
|
|
301
|
+
}
|
|
302
|
+
return args;
|
|
303
|
+
}
|
|
304
|
+
async function parseRequestBody(req, maxBytes) {
|
|
305
|
+
const declared = Number(req.headers["content-length"]);
|
|
306
|
+
if (declared > maxBytes)
|
|
307
|
+
throw new HttpError(413, `Request body exceeds ${maxBytes} bytes`);
|
|
539
308
|
return new Promise((resolve, reject) => {
|
|
540
|
-
|
|
541
|
-
|
|
309
|
+
const chunks = [];
|
|
310
|
+
let received = 0;
|
|
311
|
+
let tooLarge = false;
|
|
312
|
+
req.on("data", (chunk) => {
|
|
313
|
+
if (tooLarge)
|
|
314
|
+
return;
|
|
315
|
+
received += chunk.length;
|
|
316
|
+
if (received > maxBytes) {
|
|
317
|
+
// Keep draining so the 413 can still be written; the response closes the connection.
|
|
318
|
+
tooLarge = true;
|
|
319
|
+
chunks.length = 0;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
chunks.push(chunk);
|
|
323
|
+
});
|
|
542
324
|
req.on("end", () => {
|
|
543
|
-
if (
|
|
325
|
+
if (tooLarge)
|
|
326
|
+
return reject(new HttpError(413, `Request body exceeds ${maxBytes} bytes`));
|
|
327
|
+
if (received === 0)
|
|
544
328
|
return resolve({});
|
|
329
|
+
let parsed;
|
|
545
330
|
try {
|
|
546
|
-
|
|
331
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
547
332
|
}
|
|
548
333
|
catch {
|
|
549
|
-
reject(new
|
|
334
|
+
return reject(new HttpError(400, "Invalid JSON"));
|
|
335
|
+
}
|
|
336
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
337
|
+
return reject(new HttpError(400, "Request body must be a JSON object"));
|
|
550
338
|
}
|
|
339
|
+
resolve(parsed);
|
|
551
340
|
});
|
|
552
341
|
req.on("error", reject);
|
|
553
342
|
});
|
|
554
343
|
}
|
|
344
|
+
/** Host names (without port) that may address the daemon besides IP literals. */
|
|
345
|
+
const DEFAULT_ALLOWED_HOSTS = ["localhost", "host.docker.internal"];
|
|
346
|
+
export function allowedHostsFromEnv(env = process.env) {
|
|
347
|
+
const extra = (env.NEXUS_ALLOWED_HOSTS || "").split(",").map(h => h.trim().toLowerCase()).filter(Boolean);
|
|
348
|
+
return [...DEFAULT_ALLOWED_HOSTS, ...extra];
|
|
349
|
+
}
|
|
350
|
+
/** Hostname part of a Host header: strips the port and IPv6 brackets. */
|
|
351
|
+
function hostName(header) {
|
|
352
|
+
const m = header.match(/^\[([^\]]+)\](?::\d+)?$/);
|
|
353
|
+
if (m)
|
|
354
|
+
return m[1].toLowerCase();
|
|
355
|
+
return header.replace(/:\d+$/, "").toLowerCase();
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The daemon has no authentication, so it must not be callable from a web
|
|
359
|
+
* page the user happens to have open. Browsers attach Origin / Sec-Fetch-*
|
|
360
|
+
* headers to cross-site requests and only send JSON bodies after a CORS
|
|
361
|
+
* preflight, which the daemon never grants; MCP adapters and scripts send
|
|
362
|
+
* neither header and use application/json. A DNS-rebinding page reaches the
|
|
363
|
+
* daemon as a same-origin request under its own host name, so the Host
|
|
364
|
+
* header must be an IP literal or a known local name.
|
|
365
|
+
*/
|
|
366
|
+
function rejectBrowserRequest(req, allowedHosts) {
|
|
367
|
+
const host = req.headers.host;
|
|
368
|
+
if (!host)
|
|
369
|
+
return new HttpError(400, "Host header is required");
|
|
370
|
+
const name = hostName(host);
|
|
371
|
+
if (!net.isIP(name) && !allowedHosts.includes(name)) {
|
|
372
|
+
return new HttpError(403, `Host '${name}' is not allowed; use an IP address or add it to NEXUS_ALLOWED_HOSTS`);
|
|
373
|
+
}
|
|
374
|
+
if (req.headers.origin !== undefined) {
|
|
375
|
+
return new HttpError(403, "Requests from browser origins are not allowed");
|
|
376
|
+
}
|
|
377
|
+
const site = req.headers["sec-fetch-site"];
|
|
378
|
+
if (typeof site === "string" && site !== "none" && site !== "same-origin") {
|
|
379
|
+
return new HttpError(403, "Cross-site browser requests are not allowed");
|
|
380
|
+
}
|
|
381
|
+
if (req.method === "POST") {
|
|
382
|
+
const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
|
383
|
+
if (type !== "application/json") {
|
|
384
|
+
return new HttpError(415, "Content-Type must be application/json");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
555
389
|
// ---------------------------------------------------------------------------
|
|
556
390
|
// Server factory
|
|
557
391
|
// ---------------------------------------------------------------------------
|
|
558
392
|
export async function createDaemonServer(options) {
|
|
559
|
-
|
|
393
|
+
await initializeStorage();
|
|
394
|
+
const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
395
|
+
const allowedHosts = options.allowedHosts ?? allowedHostsFromEnv();
|
|
396
|
+
const limit = options.rateLimit ?? rateLimitFromEnv();
|
|
397
|
+
const instanceLimiter = new RateLimiter(limit.perSecond, limit.burst);
|
|
398
|
+
const addressLimiter = new RateLimiter(limit.perSecond * ADDRESS_LIMIT_FACTOR, limit.burst * ADDRESS_LIMIT_FACTOR);
|
|
399
|
+
/**
|
|
400
|
+
* Tool calls from HTTP routes, counted against the calling instance's budget.
|
|
401
|
+
* Requests that name no instance ("unknown") are only limited per address:
|
|
402
|
+
* pooling them in one shared bucket let any client exhaust it for all others.
|
|
403
|
+
*/
|
|
404
|
+
const call = (name, args, instanceId) => {
|
|
405
|
+
if (instanceId !== UNKNOWN_INSTANCE) {
|
|
406
|
+
const wait = instanceLimiter.take(`instance:${instanceId}`);
|
|
407
|
+
if (wait)
|
|
408
|
+
throw rateLimited(wait, `instance '${instanceId}'`);
|
|
409
|
+
}
|
|
410
|
+
return callTool(name, args, instanceId);
|
|
411
|
+
};
|
|
560
412
|
const server = http.createServer(async (req, res) => {
|
|
561
413
|
const method = req.method || "GET";
|
|
562
414
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
563
415
|
const path = url.pathname;
|
|
416
|
+
const parseBody = (r) => parseRequestBody(r, maxBodyBytes);
|
|
564
417
|
try {
|
|
418
|
+
const guard = rejectBrowserRequest(req, allowedHosts);
|
|
419
|
+
if (guard) {
|
|
420
|
+
req.resume();
|
|
421
|
+
return jsonResponse(res, guard.status, { ok: false, error: guard.message });
|
|
422
|
+
}
|
|
423
|
+
// Health and the tool list stay reachable so adapters can tell "busy" from "down".
|
|
424
|
+
const exempt = method === "GET" && (path === "/health" || path === "/api/tools");
|
|
425
|
+
if (!exempt) {
|
|
426
|
+
const wait = addressLimiter.take(`address:${req.socket.remoteAddress ?? "unknown"}`);
|
|
427
|
+
if (wait) {
|
|
428
|
+
req.resume();
|
|
429
|
+
const e = rateLimited(wait, "this client address");
|
|
430
|
+
return jsonResponse(res, e.status, { ok: false, error: e.message }, e.headers);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
565
433
|
// Health
|
|
566
434
|
if (method === "GET" && path === "/health") {
|
|
567
|
-
return jsonResponse(res, 200, {
|
|
568
|
-
ok: true, version: options.version,
|
|
569
|
-
storageMode: storageInfo.storageMode,
|
|
570
|
-
isDegraded: storageInfo.isDegraded,
|
|
571
|
-
ready: storageInfo.ready
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
// Storage info
|
|
575
|
-
if (method === "GET" && path === "/api/storage/info") {
|
|
576
|
-
return jsonResponse(res, 200, storageInfo);
|
|
435
|
+
return jsonResponse(res, 200, { ok: true, version: options.version });
|
|
577
436
|
}
|
|
578
437
|
// Tool definitions
|
|
579
438
|
if (method === "GET" && path === "/api/tools") {
|
|
@@ -584,93 +443,50 @@ export async function createDaemonServer(options) {
|
|
|
584
443
|
const body = await parseBody(req);
|
|
585
444
|
const toolName = body.tool;
|
|
586
445
|
const args = (body.args || {});
|
|
587
|
-
const instanceId = body.instanceId ||
|
|
446
|
+
const instanceId = body.instanceId || UNKNOWN_INSTANCE;
|
|
588
447
|
if (!toolName)
|
|
589
448
|
return jsonResponse(res, 400, { ok: false, error: "tool is required" });
|
|
590
|
-
|
|
591
|
-
|
|
449
|
+
if (!isToolName(toolName)) {
|
|
450
|
+
return jsonResponse(res, 404, { ok: false, error: `Unknown tool: '${toolName}'` });
|
|
451
|
+
}
|
|
452
|
+
return jsonResponse(res, 200, { ok: true, result: await call(toolName, args, instanceId) });
|
|
592
453
|
}
|
|
593
454
|
// Messages
|
|
594
455
|
if (method === "POST" && path === "/api/messages/send") {
|
|
595
456
|
const body = await parseBody(req);
|
|
596
|
-
|
|
597
|
-
return jsonResponse(res, 400, { ok: false, error: "message is required" });
|
|
598
|
-
const instanceId = body.instanceId || "unknown";
|
|
599
|
-
const meetingId = body.meetingId;
|
|
600
|
-
const source = body.source;
|
|
601
|
-
// Direct meetingId routing (bypasses session currentProject check)
|
|
602
|
-
if (meetingId || source === "meeting") {
|
|
603
|
-
const targetMeetingId = meetingId || (await UnifiedMeetingStore.getActiveMeeting())?.id || null;
|
|
604
|
-
if (!targetMeetingId) {
|
|
605
|
-
return jsonResponse(res, 200, { ok: false, sentTo: "global", message: "No active meeting found." });
|
|
606
|
-
}
|
|
607
|
-
const msg = {
|
|
608
|
-
from: instanceId, text: body.message,
|
|
609
|
-
category: body.category || "CHAT",
|
|
610
|
-
timestamp: new Date().toISOString()
|
|
611
|
-
};
|
|
612
|
-
await UnifiedMeetingStore.addMessage(targetMeetingId, msg);
|
|
613
|
-
return jsonResponse(res, 200, { ok: true, sentTo: "meeting", meetingId: targetMeetingId, message: `Message sent to meeting ${targetMeetingId}.` });
|
|
614
|
-
}
|
|
615
|
-
const result = await toolSendMessage({ message: body.message, category: body.category }, instanceId);
|
|
616
|
-
return jsonResponse(res, 200, result);
|
|
457
|
+
return jsonResponse(res, 200, await call("send_message", body, actor(body)));
|
|
617
458
|
}
|
|
618
459
|
if (method === "GET" && path === "/api/messages/unread") {
|
|
619
|
-
const
|
|
620
|
-
const meetingId = url.searchParams.get("meetingId") || undefined;
|
|
621
|
-
const instanceId = url.searchParams.get("instanceId") || "unknown";
|
|
622
|
-
const result = await toolReadMessages({ count, meetingId }, instanceId);
|
|
460
|
+
const result = await call("read_messages", queryArgs(url, ["count"]), url.searchParams.get("instanceId") || UNKNOWN_INSTANCE);
|
|
623
461
|
return jsonResponse(res, 200, result);
|
|
624
462
|
}
|
|
625
463
|
// Projects
|
|
626
464
|
if (method === "POST" && path === "/api/projects/sync") {
|
|
627
465
|
const body = await parseBody(req);
|
|
628
|
-
|
|
629
|
-
return jsonResponse(res, 400, { ok: false, error: "manifest is required" });
|
|
630
|
-
const result = await toolSyncProjectAssets({ manifest: body.manifest, internalDocs: body.internalDocs || "" }, body.instanceId || "unknown");
|
|
631
|
-
return jsonResponse(res, 200, { ok: true, ...result });
|
|
466
|
+
return jsonResponse(res, 200, await call("sync_project_assets", { internalDocs: "", ...body }, actor(body)));
|
|
632
467
|
}
|
|
633
468
|
if (method === "POST" && path === "/api/projects/update") {
|
|
634
469
|
const body = await parseBody(req);
|
|
635
|
-
|
|
636
|
-
return jsonResponse(res, 400, { ok: false, error: "projectId and patch are required" });
|
|
637
|
-
const result = await toolUpdateProject({ projectId: body.projectId, patch: body.patch }, body.instanceId || "unknown");
|
|
638
|
-
return jsonResponse(res, 200, { ok: true, ...result });
|
|
470
|
+
return jsonResponse(res, 200, await call("update_project", body, actor(body)));
|
|
639
471
|
}
|
|
640
472
|
if (method === "POST" && path === "/api/projects/rename") {
|
|
641
473
|
const body = await parseBody(req);
|
|
642
|
-
|
|
643
|
-
return jsonResponse(res, 400, { ok: false, error: "oldId and newId are required" });
|
|
644
|
-
const result = await toolRenameProject({ oldId: body.oldId, newId: body.newId }, body.instanceId || "unknown");
|
|
645
|
-
return jsonResponse(res, 200, { ok: true, ...result });
|
|
474
|
+
return jsonResponse(res, 200, await call("rename_project", body, actor(body)));
|
|
646
475
|
}
|
|
647
476
|
if (method === "POST" && path === "/api/projects/delete") {
|
|
648
477
|
const body = await parseBody(req);
|
|
649
|
-
|
|
650
|
-
return jsonResponse(res, 400, { ok: false, error: "projectId is required" });
|
|
651
|
-
const result = await toolHostDeleteProject({ projectId: body.projectId }, body.instanceId || "unknown");
|
|
652
|
-
return jsonResponse(res, 200, { ok: true, ...result });
|
|
478
|
+
return jsonResponse(res, 200, await call("host_delete_project", body, actor(body)));
|
|
653
479
|
}
|
|
654
480
|
if (method === "GET" && path === "/api/projects/search") {
|
|
655
|
-
|
|
656
|
-
const limit = Number(url.searchParams.get("limit")) || 10;
|
|
657
|
-
if (!query)
|
|
658
|
-
return jsonResponse(res, 400, { ok: false, error: "query is required" });
|
|
659
|
-
const result = await toolSearchProjects({ query, limit });
|
|
660
|
-
return jsonResponse(res, 200, result);
|
|
481
|
+
return jsonResponse(res, 200, await call("search_projects", queryArgs(url, ["limit"]), UNKNOWN_INSTANCE));
|
|
661
482
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
return jsonResponse(res, 200,
|
|
483
|
+
const projectMatch = path.match(/^\/api\/projects\/([^/]+)$/);
|
|
484
|
+
if (method === "GET" && projectMatch && !["search", "topology"].includes(projectMatch[1])) {
|
|
485
|
+
const includeDocs = url.searchParams.get("includeDocs") !== "false";
|
|
486
|
+
return jsonResponse(res, 200, await call("get_project", { projectId: decodeURIComponent(projectMatch[1]), includeDocs }, UNKNOWN_INSTANCE));
|
|
666
487
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const body = await parseBody(req);
|
|
670
|
-
if (!body.projectId)
|
|
671
|
-
return jsonResponse(res, 400, { ok: false, error: "projectId is required" });
|
|
672
|
-
const result = await toolRegisterSessionContext({ projectId: body.projectId }, body.instanceId || "unknown");
|
|
673
|
-
return jsonResponse(res, result.ok ? 200 : 404, result);
|
|
488
|
+
if (method === "GET" && path === "/api/projects/topology") {
|
|
489
|
+
return jsonResponse(res, 200, await call("get_global_topology", queryArgs(url, []), UNKNOWN_INSTANCE));
|
|
674
490
|
}
|
|
675
491
|
// Global docs
|
|
676
492
|
if (method === "GET" && path === "/api/global/docs") {
|
|
@@ -688,52 +504,41 @@ export async function createDaemonServer(options) {
|
|
|
688
504
|
}
|
|
689
505
|
if (method === "POST") {
|
|
690
506
|
const body = await parseBody(req);
|
|
691
|
-
|
|
692
|
-
return jsonResponse(res, 400, { ok: false, error: "title and content are required" });
|
|
693
|
-
const instanceId = body.instanceId || "unknown";
|
|
694
|
-
await StorageManager.saveGlobalDoc(docId, body.title, body.content, instanceId);
|
|
507
|
+
await call("sync_global_doc", { ...body, docId }, actor(body));
|
|
695
508
|
return jsonResponse(res, 200, { ok: true, docId });
|
|
696
509
|
}
|
|
697
510
|
}
|
|
511
|
+
if (method === "GET" && path === "/api/global/strategy") {
|
|
512
|
+
return jsonResponse(res, 200, { content: await StorageManager.getGlobalBlueprint() });
|
|
513
|
+
}
|
|
698
514
|
if (method === "POST" && path === "/api/global/strategy") {
|
|
699
515
|
const body = await parseBody(req);
|
|
700
|
-
|
|
701
|
-
return jsonResponse(res, 400, { ok: false, error: "content is required" });
|
|
702
|
-
const instanceId = body.instanceId || "unknown";
|
|
703
|
-
const result = await toolUpdateGlobalStrategy({ content: body.content }, instanceId);
|
|
704
|
-
return jsonResponse(res, 200, result);
|
|
516
|
+
return jsonResponse(res, 200, await call("update_global_strategy", body, actor(body)));
|
|
705
517
|
}
|
|
706
518
|
// Meetings
|
|
519
|
+
if (method === "GET" && path === "/api/meetings") {
|
|
520
|
+
return jsonResponse(res, 200, await call("list_meetings", queryArgs(url, ["limit"]), UNKNOWN_INSTANCE));
|
|
521
|
+
}
|
|
707
522
|
if (method === "POST" && path === "/api/meetings/start") {
|
|
708
523
|
const body = await parseBody(req);
|
|
709
|
-
|
|
710
|
-
return jsonResponse(res, 400, { ok: false, error: "topic is required" });
|
|
711
|
-
const result = await toolStartMeeting({ topic: body.topic }, body.instanceId || "unknown");
|
|
712
|
-
return jsonResponse(res, 200, result);
|
|
524
|
+
return jsonResponse(res, 200, await call("start_meeting", body, actor(body)));
|
|
713
525
|
}
|
|
714
526
|
if (method === "POST" && path === "/api/meetings/end") {
|
|
715
527
|
const body = await parseBody(req);
|
|
716
|
-
|
|
717
|
-
return jsonResponse(res, 200, result);
|
|
528
|
+
return jsonResponse(res, 200, await call("end_meeting", body, actor(body)));
|
|
718
529
|
}
|
|
719
530
|
if (method === "POST" && path === "/api/meetings/archive") {
|
|
720
531
|
const body = await parseBody(req);
|
|
721
|
-
|
|
722
|
-
return jsonResponse(res, 400, { ok: false, error: "meetingId is required" });
|
|
723
|
-
const result = await toolArchiveMeeting({ meetingId: body.meetingId }, body.instanceId || "unknown");
|
|
724
|
-
return jsonResponse(res, 200, result);
|
|
532
|
+
return jsonResponse(res, 200, await call("archive_meeting", body, actor(body)));
|
|
725
533
|
}
|
|
726
534
|
if (method === "POST" && path === "/api/meetings/reopen") {
|
|
727
535
|
const body = await parseBody(req);
|
|
728
|
-
|
|
729
|
-
return jsonResponse(res, 400, { ok: false, error: "meetingId is required" });
|
|
730
|
-
const result = await toolReopenMeeting({ meetingId: body.meetingId }, body.instanceId || "unknown");
|
|
731
|
-
return jsonResponse(res, 200, result);
|
|
536
|
+
return jsonResponse(res, 200, await call("reopen_meeting", body, actor(body)));
|
|
732
537
|
}
|
|
733
538
|
const meetingMatch = path.match(/^\/api\/meetings\/(.+)$/);
|
|
734
539
|
if (meetingMatch && method === "GET") {
|
|
735
540
|
const meetingId = decodeURIComponent(meetingMatch[1]);
|
|
736
|
-
const meeting =
|
|
541
|
+
const meeting = SqliteMeetingStore.getMeeting(meetingId);
|
|
737
542
|
if (!meeting)
|
|
738
543
|
return jsonResponse(res, 404, { ok: false, error: `Meeting '${meetingId}' not found.` });
|
|
739
544
|
return jsonResponse(res, 200, meeting);
|
|
@@ -741,51 +546,53 @@ export async function createDaemonServer(options) {
|
|
|
741
546
|
// Tasks
|
|
742
547
|
if (method === "POST" && path === "/api/tasks") {
|
|
743
548
|
const body = await parseBody(req);
|
|
744
|
-
|
|
745
|
-
return jsonResponse(res, 200, result);
|
|
549
|
+
return jsonResponse(res, 200, await call("create_task", body, actor(body)));
|
|
746
550
|
}
|
|
747
551
|
if (method === "GET" && path === "/api/tasks") {
|
|
748
|
-
|
|
749
|
-
const limit = Number(url.searchParams.get("limit")) || 50;
|
|
750
|
-
const result = await toolListTasks({ status: status || undefined, limit });
|
|
751
|
-
return jsonResponse(res, 200, result);
|
|
552
|
+
return jsonResponse(res, 200, await call("list_tasks", queryArgs(url, ["limit"]), UNKNOWN_INSTANCE));
|
|
752
553
|
}
|
|
753
554
|
const taskMatch = path.match(/^\/api\/tasks\/([^/]+)(\/(.+))?$/);
|
|
754
555
|
if (taskMatch) {
|
|
755
556
|
const taskId = decodeURIComponent(taskMatch[1]);
|
|
756
557
|
const action = taskMatch[3];
|
|
757
558
|
if (method === "GET" && !action) {
|
|
758
|
-
|
|
759
|
-
return jsonResponse(res, 200, result);
|
|
559
|
+
return jsonResponse(res, 200, await call("get_task", { taskId }, UNKNOWN_INSTANCE));
|
|
760
560
|
}
|
|
761
561
|
if (method === "POST" && action === "update") {
|
|
762
562
|
const body = await parseBody(req);
|
|
763
|
-
const
|
|
764
|
-
if (!
|
|
563
|
+
const update = taskUpdateSchema.safeParse(body);
|
|
564
|
+
if (!update.success)
|
|
565
|
+
return jsonResponse(res, 400, { ok: false, error: `Invalid task update:\n${z.prettifyError(update.error)}` });
|
|
566
|
+
if (!getTask(taskId))
|
|
765
567
|
return jsonResponse(res, 404, { ok: false, error: `Task '${taskId}' not found.` });
|
|
766
|
-
|
|
767
|
-
return jsonResponse(res, 200, { ok: true, task: updated });
|
|
568
|
+
return jsonResponse(res, 200, { ok: true, task: updateTask(taskId, update.data) });
|
|
768
569
|
}
|
|
769
570
|
if (method === "POST" && action === "cancel") {
|
|
770
|
-
|
|
771
|
-
return jsonResponse(res, 200, result);
|
|
571
|
+
return jsonResponse(res, 200, await call("cancel_task", { taskId }, UNKNOWN_INSTANCE));
|
|
772
572
|
}
|
|
773
573
|
}
|
|
774
574
|
// Maintenance
|
|
775
575
|
if (method === "POST" && path === "/api/maintenance/logs") {
|
|
776
576
|
const body = await parseBody(req);
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
577
|
+
return jsonResponse(res, 200, await call("host_maintenance", body, actor(body)));
|
|
578
|
+
}
|
|
579
|
+
// Events
|
|
580
|
+
if (method === "GET" && path === "/api/events") {
|
|
581
|
+
return jsonResponse(res, 200, await call("list_events", queryArgs(url, ["limit"]), UNKNOWN_INSTANCE));
|
|
781
582
|
}
|
|
782
583
|
jsonResponse(res, 404, { ok: false, error: "Not found" });
|
|
783
584
|
}
|
|
784
585
|
catch (e) {
|
|
785
586
|
const message = e instanceof Error ? e.message : String(e);
|
|
587
|
+
if (e instanceof HttpError) {
|
|
588
|
+
return jsonResponse(res, e.status, { ok: false, error: message }, e.headers);
|
|
589
|
+
}
|
|
590
|
+
if (e instanceof ToolValidationError || e instanceof z.ZodError) {
|
|
591
|
+
return jsonResponse(res, 400, { ok: false, error: e instanceof z.ZodError ? z.prettifyError(e) : message });
|
|
592
|
+
}
|
|
786
593
|
console.error("[n2n-nexus daemon] Error:", message);
|
|
787
594
|
jsonResponse(res, 500, { ok: false, error: message });
|
|
788
595
|
}
|
|
789
596
|
});
|
|
790
|
-
return { server
|
|
597
|
+
return { server };
|
|
791
598
|
}
|