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/server/nexus.js
CHANGED
|
@@ -1,54 +1,97 @@
|
|
|
1
|
-
import { Server } from "@modelcontextprotocol/
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { NexusClient } from "../client/nexus-client.js";
|
|
1
|
+
import { CLIENT_INFO_META_KEY, ProtocolError, ProtocolErrorCode, Server, } from "@modelcontextprotocol/server";
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { NexusClient, NexusToolError, NexusTransportError } from "../client/nexus-client.js";
|
|
5
4
|
import { pkg } from "../config/index.js";
|
|
5
|
+
import { resolveInstanceId } from "./instance-id.js";
|
|
6
6
|
const RETRY_INTERVAL_MS = 3000;
|
|
7
|
+
/** How long the first tools/list waits for the startup daemon fetch before answering with what it has. */
|
|
8
|
+
const INITIAL_LIST_WAIT_MS = 1000;
|
|
7
9
|
const SERVICE_NAME = "n2n-nexus";
|
|
10
|
+
/**
|
|
11
|
+
* Stateless MCP adapter in front of the daemon.
|
|
12
|
+
*
|
|
13
|
+
* Served through the v2 SDK's serveStdio entry, which answers both
|
|
14
|
+
* protocol eras on one connection: 2026-07-28 clients (per-request `_meta`
|
|
15
|
+
* envelope, `server/discover`, `subscriptions/listen`) and 2025-era clients
|
|
16
|
+
* (`initialize` handshake).
|
|
17
|
+
*/
|
|
8
18
|
export class NexusServer {
|
|
9
|
-
server;
|
|
19
|
+
server = null;
|
|
20
|
+
handle = null;
|
|
10
21
|
client;
|
|
11
|
-
instanceId;
|
|
12
22
|
cachedTools = [];
|
|
13
23
|
retryTimer = null;
|
|
24
|
+
/** True while a reconnect loop is active (waiting or attempting). */
|
|
25
|
+
retrying = false;
|
|
26
|
+
retryIntervalMs;
|
|
14
27
|
connected = false;
|
|
15
|
-
|
|
28
|
+
stopped = false;
|
|
29
|
+
/**
|
|
30
|
+
* First client name seen on this stdio connection. clientInfo is only a
|
|
31
|
+
* SHOULD per request under 2026-07-28, so a request without it must not
|
|
32
|
+
* fall back to a different instance id than its siblings.
|
|
33
|
+
*/
|
|
34
|
+
clientName;
|
|
35
|
+
/** The first daemon fetch, started once serving has begun. */
|
|
36
|
+
initialFetch = null;
|
|
37
|
+
constructor(options = {}) {
|
|
16
38
|
const endpoint = process.env.NEXUS_ENDPOINT || "http://127.0.0.1:5688";
|
|
17
|
-
|
|
18
|
-
|
|
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();
|
|
39
|
+
this.retryIntervalMs = options.retryIntervalMs ?? RETRY_INTERVAL_MS;
|
|
40
|
+
this.client = new NexusClient({ endpoint, timeoutMs: options.requestTimeoutMs ?? 5000 });
|
|
24
41
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
/** Builds the MCP server instance serveStdio pins for the connection. */
|
|
43
|
+
buildServer() {
|
|
44
|
+
const server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { tools: { listChanged: true } } });
|
|
45
|
+
server.setRequestHandler("tools/list", async () => {
|
|
46
|
+
// Give a local daemon a moment so the first list is complete; never block on a hung one.
|
|
47
|
+
if (!this.connected && this.initialFetch) {
|
|
48
|
+
await Promise.race([this.initialFetch, new Promise((r) => setTimeout(r, INITIAL_LIST_WAIT_MS))]);
|
|
49
|
+
}
|
|
50
|
+
return { tools: this.cachedTools };
|
|
51
|
+
});
|
|
52
|
+
server.setRequestHandler("tools/call", async (request, ctx) => {
|
|
30
53
|
if (!this.connected) {
|
|
31
54
|
return {
|
|
32
55
|
content: [{ type: "text", text: "Daemon is not ready yet. Please wait a moment and retry." }],
|
|
33
56
|
isError: true
|
|
34
57
|
};
|
|
35
58
|
}
|
|
59
|
+
// Unknown tool is a protocol error (JSON-RPC -32602), not a tool execution error.
|
|
60
|
+
const unknownTool = () => new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
|
|
61
|
+
if (!this.cachedTools.some((t) => t.name === request.params.name))
|
|
62
|
+
throw unknownTool();
|
|
63
|
+
// 2026-07-28 clients identify themselves per request; 2025-era clients did so in initialize.
|
|
64
|
+
const envelope = ctx.mcpReq.envelope;
|
|
65
|
+
this.clientName ??= envelope?.[CLIENT_INFO_META_KEY]?.name
|
|
66
|
+
?? server.getClientVersion()?.name;
|
|
67
|
+
const instanceId = resolveInstanceId(process.env, process.argv, process.cwd(), this.clientName);
|
|
36
68
|
try {
|
|
37
|
-
const result = await this.client.callTool(request.params.name, request.params.arguments,
|
|
69
|
+
const result = await this.client.callTool(request.params.name, request.params.arguments, instanceId);
|
|
70
|
+
// Every daemon result is a JSON object, so it goes out as structuredContent
|
|
71
|
+
// (data tools also publish an outputSchema for it; write tools do not). The
|
|
72
|
+
// JSON text keeps clients that only read content working (the spec's
|
|
73
|
+
// backwards-compatibility advice).
|
|
38
74
|
return {
|
|
39
|
-
content: [{
|
|
40
|
-
|
|
41
|
-
text: typeof result === "string" ? result : JSON.stringify(result, null, 2)
|
|
42
|
-
}]
|
|
75
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
76
|
+
structuredContent: result
|
|
43
77
|
};
|
|
44
78
|
}
|
|
45
79
|
catch (e) {
|
|
46
80
|
const message = e instanceof Error ? e.message : String(e);
|
|
47
|
-
//
|
|
81
|
+
// The daemon dropped the tool since the list was fetched: resync so tools/list stops offering it.
|
|
82
|
+
if (e instanceof NexusToolError && e.status === 404) {
|
|
83
|
+
await this.refreshTools();
|
|
84
|
+
throw unknownTool();
|
|
85
|
+
}
|
|
86
|
+
if (!(e instanceof NexusTransportError)) {
|
|
87
|
+
// The daemon answered; the tool itself failed. Keep the tool list.
|
|
88
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
89
|
+
}
|
|
90
|
+
// Daemon went away — drop the tool list and start the retry loop
|
|
48
91
|
if (this.connected) {
|
|
49
92
|
this.connected = false;
|
|
50
93
|
this.cachedTools = [];
|
|
51
|
-
await this.
|
|
94
|
+
await this.notifyToolsChanged();
|
|
52
95
|
this.scheduleRetry();
|
|
53
96
|
}
|
|
54
97
|
return {
|
|
@@ -57,41 +100,107 @@ export class NexusServer {
|
|
|
57
100
|
};
|
|
58
101
|
}
|
|
59
102
|
});
|
|
103
|
+
this.server = server;
|
|
104
|
+
return server;
|
|
105
|
+
}
|
|
106
|
+
/** Delivered unsolicited on 2025-era connections and via subscriptions/listen on 2026-07-28 ones. */
|
|
107
|
+
async notifyToolsChanged() {
|
|
108
|
+
await this.server?.sendToolListChanged().catch(() => { });
|
|
109
|
+
}
|
|
110
|
+
/** Re-fetch the daemon's tool list; notify the client if it changed. */
|
|
111
|
+
async refreshTools() {
|
|
112
|
+
try {
|
|
113
|
+
const tools = await this.client.fetchTools();
|
|
114
|
+
const before = JSON.stringify(this.cachedTools);
|
|
115
|
+
this.cachedTools = tools;
|
|
116
|
+
if (JSON.stringify(tools) !== before)
|
|
117
|
+
await this.notifyToolsChanged();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Transport trouble is handled by the next call; keep the current list.
|
|
121
|
+
}
|
|
60
122
|
}
|
|
61
123
|
async tryConnect() {
|
|
62
124
|
try {
|
|
63
125
|
const tools = await this.client.fetchTools();
|
|
126
|
+
// JSON Schema produced by the daemon; the SDK models it as JSON values.
|
|
64
127
|
this.cachedTools = tools;
|
|
65
128
|
this.connected = true;
|
|
66
129
|
console.error(`[n2n-nexus] Connected to daemon. ${tools.length} tools loaded.`);
|
|
67
|
-
await this.
|
|
130
|
+
await this.notifyToolsChanged();
|
|
68
131
|
return true;
|
|
69
132
|
}
|
|
70
133
|
catch {
|
|
71
134
|
return false;
|
|
72
135
|
}
|
|
73
136
|
}
|
|
137
|
+
stopRetry() {
|
|
138
|
+
this.retrying = false;
|
|
139
|
+
if (this.retryTimer) {
|
|
140
|
+
clearTimeout(this.retryTimer);
|
|
141
|
+
this.retryTimer = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Stop reconnecting and close the MCP connection. Safe to call more than once. */
|
|
145
|
+
async shutdown() {
|
|
146
|
+
if (this.stopped)
|
|
147
|
+
return;
|
|
148
|
+
this.stopped = true;
|
|
149
|
+
this.stopRetry();
|
|
150
|
+
await this.handle?.close().catch(() => { });
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Reconnect loop. Each attempt is scheduled only after the previous one
|
|
154
|
+
* finished, so a daemon that hangs (attempt longer than the interval)
|
|
155
|
+
* never accumulates concurrent requests.
|
|
156
|
+
*/
|
|
74
157
|
scheduleRetry() {
|
|
75
|
-
if (this.
|
|
158
|
+
if (this.retrying || this.stopped)
|
|
76
159
|
return;
|
|
160
|
+
this.retrying = true;
|
|
77
161
|
const endpoint = process.env.NEXUS_ENDPOINT || "http://127.0.0.1:5688";
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
162
|
+
const next = () => {
|
|
163
|
+
this.retryTimer = setTimeout(async () => {
|
|
164
|
+
console.error(`[n2n-nexus] Waiting for daemon at ${endpoint}...`);
|
|
165
|
+
const ok = await this.tryConnect();
|
|
166
|
+
if (!this.retrying)
|
|
167
|
+
return; // stopped while the attempt was in flight
|
|
168
|
+
// A tools/call may have lost the daemon again while this attempt was still
|
|
169
|
+
// notifying the client; only stop if the connection is still up.
|
|
170
|
+
if (ok && this.connected)
|
|
171
|
+
this.stopRetry();
|
|
172
|
+
else
|
|
173
|
+
next();
|
|
174
|
+
}, this.retryIntervalMs);
|
|
175
|
+
};
|
|
176
|
+
next();
|
|
86
177
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Serve MCP. Per the stdio transport spec the server should exit when its
|
|
180
|
+
* input closes; without that the reconnect timer would keep the adapter
|
|
181
|
+
* alive after the IDE went away.
|
|
182
|
+
*/
|
|
183
|
+
async run(options = {}) {
|
|
184
|
+
const stdio = !options.transport;
|
|
185
|
+
const input = options.input ?? (stdio ? process.stdin : undefined);
|
|
186
|
+
const onExit = options.onExit ?? (() => process.exit(0));
|
|
187
|
+
if (input) {
|
|
188
|
+
const exit = () => { void this.shutdown().then(onExit); };
|
|
189
|
+
input.once("end", exit);
|
|
190
|
+
input.once("close", exit);
|
|
191
|
+
}
|
|
192
|
+
// Serve first so discover/initialize are answered even when the daemon hangs;
|
|
193
|
+
// tools/list briefly waits for this fetch (2026-07-28 clients only hear
|
|
194
|
+
// list_changed if they subscribe).
|
|
195
|
+
this.handle = serveStdio(() => this.buildServer(), {
|
|
196
|
+
transport: options.transport,
|
|
197
|
+
onerror: (err) => console.error("[n2n-nexus] MCP transport error:", err.message)
|
|
198
|
+
});
|
|
199
|
+
this.initialFetch = this.tryConnect();
|
|
200
|
+
const ok = await this.initialFetch;
|
|
201
|
+
if (!ok && !this.stopped) {
|
|
93
202
|
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 ${
|
|
203
|
+
console.error(`[n2n-nexus] Daemon not available at ${endpoint}. Will retry every ${this.retryIntervalMs / 1000}s...`);
|
|
95
204
|
this.scheduleRetry();
|
|
96
205
|
}
|
|
97
206
|
}
|
package/build/storage/docs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { NexusPaths } from "./paths.js";
|
|
3
3
|
import { FILE_ENCODING } from "../constants.js";
|
|
4
|
+
import { toDict, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
|
|
4
5
|
export class DocStorage {
|
|
5
6
|
static async ensureDirectory() {
|
|
6
7
|
await fs.mkdir(NexusPaths.docsDir(), { recursive: true });
|
|
@@ -8,7 +9,7 @@ export class DocStorage {
|
|
|
8
9
|
await fs.access(NexusPaths.docsIndexFile);
|
|
9
10
|
}
|
|
10
11
|
catch {
|
|
11
|
-
await
|
|
12
|
+
await writeJsonAtomic(NexusPaths.docsIndexFile, {});
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
static async readIndex() {
|
|
@@ -17,26 +18,32 @@ export class DocStorage {
|
|
|
17
18
|
const raw = await fs.readFile(NexusPaths.docsIndexFile, FILE_ENCODING);
|
|
18
19
|
const parsed = JSON.parse(raw);
|
|
19
20
|
if (parsed && typeof parsed === "object")
|
|
20
|
-
return parsed;
|
|
21
|
+
return toDict(Object.entries(parsed));
|
|
21
22
|
}
|
|
22
23
|
catch {
|
|
23
24
|
// fall through
|
|
24
25
|
}
|
|
25
|
-
return
|
|
26
|
+
return toDict();
|
|
26
27
|
}
|
|
27
28
|
static async writeIndex(index) {
|
|
28
29
|
await this.ensureDirectory();
|
|
29
|
-
await
|
|
30
|
+
await writeJsonAtomic(NexusPaths.docsIndexFile, index);
|
|
30
31
|
}
|
|
31
32
|
static async init() {
|
|
32
33
|
await this.ensureDirectory();
|
|
34
|
+
}
|
|
35
|
+
/** The master strategy document (global/blueprint.md). */
|
|
36
|
+
static async getBlueprint() {
|
|
33
37
|
try {
|
|
34
|
-
await fs.
|
|
38
|
+
return await fs.readFile(NexusPaths.globalBlueprint, FILE_ENCODING);
|
|
35
39
|
}
|
|
36
40
|
catch {
|
|
37
|
-
|
|
41
|
+
return "";
|
|
38
42
|
}
|
|
39
43
|
}
|
|
44
|
+
static async saveBlueprint(content) {
|
|
45
|
+
await writeFileAtomic(NexusPaths.globalBlueprint, content);
|
|
46
|
+
}
|
|
40
47
|
static async listDocs() {
|
|
41
48
|
await this.ensureDirectory();
|
|
42
49
|
return this.readIndex();
|
|
@@ -55,7 +62,7 @@ export class DocStorage {
|
|
|
55
62
|
await this.ensureDirectory();
|
|
56
63
|
const index = await this.readIndex();
|
|
57
64
|
const filePath = NexusPaths.docFile(docId);
|
|
58
|
-
await
|
|
65
|
+
await writeFileAtomic(filePath, content);
|
|
59
66
|
index[docId] = {
|
|
60
67
|
title,
|
|
61
68
|
lastUpdated: new Date().toISOString(),
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getDatabase } from "./sqlite.js";
|
|
2
|
+
function toEvent(row) {
|
|
3
|
+
return {
|
|
4
|
+
id: row.id,
|
|
5
|
+
timestamp: row.timestamp,
|
|
6
|
+
actor: row.actor,
|
|
7
|
+
action: row.action,
|
|
8
|
+
target: row.target ?? undefined,
|
|
9
|
+
detail: row.detail ? JSON.parse(row.detail) : undefined
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Structured audit trail of state changes made through the daemon
|
|
14
|
+
* (project synced, meeting ended, ...). Kept separate from the chat
|
|
15
|
+
* streams so assistants can read conversation and activity independently.
|
|
16
|
+
*/
|
|
17
|
+
export class EventStore {
|
|
18
|
+
static record(actor, action, target, detail) {
|
|
19
|
+
const timestamp = new Date().toISOString();
|
|
20
|
+
const result = getDatabase().prepare("INSERT INTO events (timestamp, actor, action, target, detail) VALUES (?, ?, ?, ?, ?)").run(timestamp, actor, action, target ?? null, detail ? JSON.stringify(detail) : null);
|
|
21
|
+
return { id: Number(result.lastInsertRowid), timestamp, actor, action, target, detail };
|
|
22
|
+
}
|
|
23
|
+
/** Newest events first. `action` filters by exact action name or a `prefix.` */
|
|
24
|
+
static list(limit, action) {
|
|
25
|
+
const db = getDatabase();
|
|
26
|
+
const rows = (action
|
|
27
|
+
? db.prepare("SELECT * FROM events WHERE action = ? OR action LIKE ? ORDER BY id DESC LIMIT ?").all(action, `${action}.%`, limit)
|
|
28
|
+
: db.prepare("SELECT * FROM events ORDER BY id DESC LIMIT ?").all(limit));
|
|
29
|
+
return rows.map(toEvent);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AsyncMutex } from "../utils/async-mutex.js";
|
|
4
|
+
import { FILE_ENCODING } from "../constants.js";
|
|
5
|
+
/**
|
|
6
|
+
* Serializes every file-store write inside the daemon. HTTP requests are
|
|
7
|
+
* handled concurrently, so read-modify-write sequences on registry.json,
|
|
8
|
+
* manifests and the docs index must be queued behind one lock.
|
|
9
|
+
*/
|
|
10
|
+
export const fileWriteLock = new AsyncMutex();
|
|
11
|
+
/**
|
|
12
|
+
* Write to a temp file in the same directory, then rename over the target so
|
|
13
|
+
* readers never observe a partially written file.
|
|
14
|
+
*/
|
|
15
|
+
export async function writeFileAtomic(filePath, data) {
|
|
16
|
+
const dir = path.dirname(filePath);
|
|
17
|
+
await fs.mkdir(dir, { recursive: true });
|
|
18
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`);
|
|
19
|
+
try {
|
|
20
|
+
await fs.writeFile(tmp, data, typeof data === "string" ? FILE_ENCODING : undefined);
|
|
21
|
+
await fs.rename(tmp, filePath);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Copy entries into an object without a prototype. Registry and index maps
|
|
30
|
+
* are keyed by user-supplied ids; on a plain object ids such as
|
|
31
|
+
* "constructor" or "__proto__" hit Object.prototype on lookup or rewrite the
|
|
32
|
+
* prototype on assignment.
|
|
33
|
+
*/
|
|
34
|
+
export function toDict(entries = []) {
|
|
35
|
+
const dict = Object.create(null);
|
|
36
|
+
for (const [k, v] of entries)
|
|
37
|
+
dict[k] = v;
|
|
38
|
+
return dict;
|
|
39
|
+
}
|
|
40
|
+
export function writeJsonAtomic(filePath, value) {
|
|
41
|
+
return writeFileAtomic(filePath, JSON.stringify(value, null, 2));
|
|
42
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getDatabase } from "./sqlite.js";
|
|
2
|
+
function toMessage(row) {
|
|
3
|
+
return { id: row.id, from: row.sender, text: row.text, category: row.category ?? undefined, timestamp: row.timestamp };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Global (non-meeting) chat stream, stored in SQLite so appends are cheap,
|
|
7
|
+
* concurrent-safe and addressable by id for per-instance unread cursors.
|
|
8
|
+
*/
|
|
9
|
+
export class GlobalMessageStore {
|
|
10
|
+
static addMessage(from, text, category) {
|
|
11
|
+
const db = getDatabase();
|
|
12
|
+
const timestamp = new Date().toISOString();
|
|
13
|
+
const result = db.prepare("INSERT INTO global_messages (sender, text, category, timestamp) VALUES (?, ?, ?, ?)").run(from, text, category ?? null, timestamp);
|
|
14
|
+
return { id: Number(result.lastInsertRowid), from, text, category, timestamp };
|
|
15
|
+
}
|
|
16
|
+
/** Messages this instance has not read yet, oldest first; advances the cursor. */
|
|
17
|
+
static readUnread(instanceId, count) {
|
|
18
|
+
const db = getDatabase();
|
|
19
|
+
const cursor = db.prepare("SELECT last_read_id FROM read_cursors WHERE instance_id = ? AND context_type = 'global' AND context_id = 'global'").get(instanceId);
|
|
20
|
+
const lastReadId = cursor?.last_read_id ?? 0;
|
|
21
|
+
const rows = db.prepare("SELECT id, sender, text, category, timestamp FROM global_messages WHERE id > ? ORDER BY id ASC LIMIT ?").all(lastReadId, count);
|
|
22
|
+
if (rows.length > 0) {
|
|
23
|
+
db.prepare("INSERT OR REPLACE INTO read_cursors (instance_id, context_type, context_id, last_read_id, updated_at) VALUES (?, 'global', 'global', ?, ?)").run(instanceId, rows[rows.length - 1].id, new Date().toISOString());
|
|
24
|
+
}
|
|
25
|
+
return rows.map(toMessage);
|
|
26
|
+
}
|
|
27
|
+
/** Most recent messages regardless of cursor, oldest first. */
|
|
28
|
+
static recent(count) {
|
|
29
|
+
const rows = getDatabase().prepare("SELECT id, sender, text, category, timestamp FROM global_messages ORDER BY id DESC LIMIT ?").all(count);
|
|
30
|
+
return rows.reverse().map(toMessage);
|
|
31
|
+
}
|
|
32
|
+
static count() {
|
|
33
|
+
return getDatabase().prepare("SELECT COUNT(*) AS n FROM global_messages").get().n;
|
|
34
|
+
}
|
|
35
|
+
/** Delete the oldest `count` messages. */
|
|
36
|
+
static pruneOldest(count) {
|
|
37
|
+
if (count <= 0)
|
|
38
|
+
return 0;
|
|
39
|
+
return getDatabase().prepare("DELETE FROM global_messages WHERE id IN (SELECT id FROM global_messages ORDER BY id ASC LIMIT ?)").run(count).changes;
|
|
40
|
+
}
|
|
41
|
+
static clear() {
|
|
42
|
+
return getDatabase().prepare("DELETE FROM global_messages").run().changes;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/build/storage/index.js
CHANGED
|
@@ -3,38 +3,28 @@ import { FILE_ENCODING } from "../constants.js";
|
|
|
3
3
|
import { NexusPaths } from "./paths.js";
|
|
4
4
|
import { RegistryStorage } from "./registry.js";
|
|
5
5
|
import { ProjectStorage } from "./projects.js";
|
|
6
|
-
import { LogStorage } from "./logs.js";
|
|
7
6
|
import { DocStorage } from "./docs.js";
|
|
7
|
+
import { fileWriteLock, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
|
|
8
|
+
export const BLUEPRINT_PLACEHOLDER = "# Global Coordination Blueprint\n\nShared meeting space.";
|
|
8
9
|
export class StorageManager {
|
|
9
10
|
static initialized = false;
|
|
10
11
|
// --- Path Proxies (Backward Compatibility) ---
|
|
11
12
|
static get globalDir() { return NexusPaths.globalDir; }
|
|
12
13
|
static get globalBlueprint() { return NexusPaths.globalBlueprint; }
|
|
13
|
-
static get globalDiscussion() { return NexusPaths.globalDiscussion; }
|
|
14
14
|
static get projectsRoot() { return NexusPaths.projectsRoot; }
|
|
15
15
|
static get registryFile() { return NexusPaths.registryFile; }
|
|
16
|
-
static get archivesDir() { return NexusPaths.archivesDir; }
|
|
17
16
|
static async init() {
|
|
18
17
|
if (this.initialized)
|
|
19
18
|
return;
|
|
20
19
|
await fs.mkdir(NexusPaths.root, { recursive: true });
|
|
21
20
|
await fs.mkdir(this.globalDir, { recursive: true });
|
|
22
21
|
await fs.mkdir(this.projectsRoot, { recursive: true });
|
|
23
|
-
await fs.mkdir(this.archivesDir, { recursive: true });
|
|
24
22
|
// Self-healing initialization for critical files
|
|
25
23
|
await this.loadJsonSafe(this.registryFile, { projects: {} });
|
|
26
|
-
await this.loadJsonSafe(this.globalDiscussion, []);
|
|
27
24
|
if (!await this.exists(this.globalBlueprint)) {
|
|
28
|
-
await
|
|
25
|
+
await writeFileAtomic(this.globalBlueprint, BLUEPRINT_PLACEHOLDER);
|
|
29
26
|
}
|
|
30
27
|
await DocStorage.init();
|
|
31
|
-
try {
|
|
32
|
-
const { initTasksTable } = await import("./tasks.js");
|
|
33
|
-
initTasksTable();
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
/* tasks module is optional */
|
|
37
|
-
}
|
|
38
28
|
this.initialized = true;
|
|
39
29
|
}
|
|
40
30
|
static resetInit() {
|
|
@@ -43,7 +33,7 @@ export class StorageManager {
|
|
|
43
33
|
static async loadJsonSafe(filePath, defaultValue) {
|
|
44
34
|
try {
|
|
45
35
|
if (!await this.exists(filePath)) {
|
|
46
|
-
await
|
|
36
|
+
await writeJsonAtomic(filePath, defaultValue);
|
|
47
37
|
return defaultValue;
|
|
48
38
|
}
|
|
49
39
|
const content = await fs.readFile(filePath, FILE_ENCODING);
|
|
@@ -51,7 +41,7 @@ export class StorageManager {
|
|
|
51
41
|
}
|
|
52
42
|
catch {
|
|
53
43
|
console.warn(`[Nexus Storage] Repairing corrupted file: ${filePath}`);
|
|
54
|
-
await
|
|
44
|
+
await writeJsonAtomic(filePath, defaultValue);
|
|
55
45
|
return defaultValue;
|
|
56
46
|
}
|
|
57
47
|
}
|
|
@@ -64,42 +54,55 @@ export class StorageManager {
|
|
|
64
54
|
return false;
|
|
65
55
|
}
|
|
66
56
|
}
|
|
57
|
+
// Every mutation of the file store goes through this lock; reads are
|
|
58
|
+
// lock-free because writes are atomic (temp file + rename).
|
|
59
|
+
static write(fn) {
|
|
60
|
+
return fileWriteLock.withLock(fn);
|
|
61
|
+
}
|
|
67
62
|
// --- Registry Methods ---
|
|
68
63
|
static listRegistry() { return RegistryStorage.listRegistry(); }
|
|
69
64
|
static getProjectManifest(id) { return RegistryStorage.getProjectManifest(id); }
|
|
70
|
-
static saveProjectManifest(manifest) { return RegistryStorage.saveProjectManifest(manifest); }
|
|
71
|
-
|
|
65
|
+
static saveProjectManifest(manifest) { return this.write(() => RegistryStorage.saveProjectManifest(manifest)); }
|
|
66
|
+
/** Manifest and internal docs in one critical section, so a concurrent delete cannot interleave. */
|
|
67
|
+
static saveProject(manifest, docs) {
|
|
68
|
+
return this.write(async () => {
|
|
69
|
+
await RegistryStorage.saveProjectManifest(manifest);
|
|
70
|
+
await ProjectStorage.saveProjectDocs(manifest.id, docs);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
static patchProjectManifest(id, patch) { return this.write(() => RegistryStorage.patchProjectManifest(id, patch)); }
|
|
72
74
|
static calculateTopology(projectId) { return RegistryStorage.calculateTopology(projectId); }
|
|
73
75
|
// --- Project Methods ---
|
|
74
76
|
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
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
static saveProjectDocs(id, content) { return this.write(() => ProjectStorage.saveProjectDocs(id, content)); }
|
|
78
|
+
static saveAsset(id, fileName, content) { return this.write(() => ProjectStorage.saveAsset(id, fileName, content)); }
|
|
79
|
+
static deleteProject(id) {
|
|
80
|
+
return this.write(async () => {
|
|
81
|
+
await ProjectStorage.deleteProject(id);
|
|
82
|
+
await RegistryStorage.deleteProject(id);
|
|
83
|
+
});
|
|
80
84
|
}
|
|
81
|
-
static
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
static renameProject(oldId, newId) {
|
|
86
|
+
return this.write(async () => {
|
|
87
|
+
await RegistryStorage.renameProject(oldId, newId);
|
|
88
|
+
try {
|
|
89
|
+
const result = await ProjectStorage.renameProject(oldId, newId);
|
|
90
|
+
if (result === 0) {
|
|
91
|
+
await RegistryStorage.renameProject(newId, oldId);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
87
94
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
await RegistryStorage.renameProject(newId, oldId).catch(() => { });
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
94
100
|
}
|
|
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
101
|
// --- Doc Methods ---
|
|
101
102
|
static listGlobalDocs() { return DocStorage.listDocs(); }
|
|
102
103
|
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); }
|
|
104
|
+
static saveGlobalDoc(docId, title, content, updatedBy) { return this.write(() => DocStorage.saveDoc(docId, title, content, updatedBy)); }
|
|
105
|
+
static deleteGlobalDoc(docId) { return this.write(() => DocStorage.deleteDoc(docId)); }
|
|
106
|
+
static getGlobalBlueprint() { return DocStorage.getBlueprint(); }
|
|
107
|
+
static saveGlobalBlueprint(content) { return this.write(() => DocStorage.saveBlueprint(content)); }
|
|
105
108
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NexusPaths } from "./paths.js";
|
|
4
|
+
import { GlobalMessageStore } from "./global-messages.js";
|
|
5
|
+
import { DocStorage } from "./docs.js";
|
|
6
|
+
import { BLUEPRINT_PLACEHOLDER } from "./index.js";
|
|
7
|
+
import { getDatabase } from "./sqlite.js";
|
|
8
|
+
import { MESSAGE_CATEGORIES } from "../types.js";
|
|
9
|
+
import { FILE_ENCODING } from "../constants.js";
|
|
10
|
+
/**
|
|
11
|
+
* One-time upgrades of data written by earlier releases. Each step is
|
|
12
|
+
* idempotent: it runs only while the legacy artifact is present and the
|
|
13
|
+
* new location is still empty, then renames the artifact out of the way.
|
|
14
|
+
*/
|
|
15
|
+
/** 0.5.0 kept the global chat in global/discussion.json; import it into SQLite. */
|
|
16
|
+
export async function importLegacyDiscussion() {
|
|
17
|
+
const file = path.join(NexusPaths.globalDir, "discussion.json");
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = await fs.readFile(file, FILE_ENCODING);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
if (GlobalMessageStore.count() > 0)
|
|
26
|
+
return 0;
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
console.warn(`[n2n-nexus] Skipping unreadable legacy chat log: ${file}`);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(entries))
|
|
36
|
+
return 0;
|
|
37
|
+
const insert = getDatabase().prepare("INSERT INTO global_messages (sender, text, category, timestamp) VALUES (?, ?, ?, ?)");
|
|
38
|
+
const imported = getDatabase().transaction((rows) => {
|
|
39
|
+
let n = 0;
|
|
40
|
+
for (const row of rows) {
|
|
41
|
+
if (!row || typeof row !== "object")
|
|
42
|
+
continue;
|
|
43
|
+
const e = row;
|
|
44
|
+
if (typeof e.text !== "string")
|
|
45
|
+
continue;
|
|
46
|
+
const category = MESSAGE_CATEGORIES.includes(String(e.category)) ? e.category : null;
|
|
47
|
+
insert.run(typeof e.from === "string" && e.from ? e.from : "unknown", e.text, category, typeof e.timestamp === "string" && e.timestamp ? e.timestamp : new Date().toISOString());
|
|
48
|
+
n++;
|
|
49
|
+
}
|
|
50
|
+
return n;
|
|
51
|
+
})(entries);
|
|
52
|
+
await fs.rename(file, `${file}.migrated`);
|
|
53
|
+
console.error(`[n2n-nexus] Imported ${imported} legacy global messages from discussion.json`);
|
|
54
|
+
return imported;
|
|
55
|
+
}
|
|
56
|
+
/** Title 0.5.0's update_global_strategy gave the doc it wrote; a user doc with another title is left alone. */
|
|
57
|
+
export const LEGACY_STRATEGY_TITLE = "Global Collaboration Strategy";
|
|
58
|
+
/**
|
|
59
|
+
* 0.5.0's update_global_strategy saved the strategy as the global doc
|
|
60
|
+
* "strategy"; it now lives in global/blueprint.md. Move it there unless the
|
|
61
|
+
* blueprint has already been written.
|
|
62
|
+
*/
|
|
63
|
+
export async function importLegacyStrategy() {
|
|
64
|
+
const docs = await DocStorage.listDocs();
|
|
65
|
+
if (docs.strategy?.title !== LEGACY_STRATEGY_TITLE)
|
|
66
|
+
return false;
|
|
67
|
+
const legacy = await DocStorage.getDoc("strategy");
|
|
68
|
+
const current = await DocStorage.getBlueprint();
|
|
69
|
+
if (legacy && (!current || current === BLUEPRINT_PLACEHOLDER)) {
|
|
70
|
+
await DocStorage.saveBlueprint(legacy);
|
|
71
|
+
}
|
|
72
|
+
await DocStorage.deleteDoc("strategy");
|
|
73
|
+
console.error("[n2n-nexus] Moved legacy global doc 'strategy' into global/blueprint.md");
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
export async function runMigrations() {
|
|
77
|
+
await importLegacyDiscussion();
|
|
78
|
+
await importLegacyStrategy();
|
|
79
|
+
}
|