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.
Files changed (39) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +74 -117
  3. package/assets/n2n-nexus-banner.jpg +0 -0
  4. package/assets/n2n-nexus-icon.png +0 -0
  5. package/assets/n2n-nexus-logo.png +0 -0
  6. package/build/client/nexus-client.js +30 -14
  7. package/build/config/cli.js +0 -3
  8. package/build/config/index.js +0 -10
  9. package/build/constants.js +7 -11
  10. package/build/daemon/index.js +35 -19
  11. package/build/daemon/rate-limit.js +62 -0
  12. package/build/daemon/server.js +349 -542
  13. package/build/daemon/tool-outputs.js +155 -0
  14. package/build/daemon/tools.js +271 -0
  15. package/build/index.js +1 -1
  16. package/build/server/instance-id.js +19 -0
  17. package/build/server/nexus.js +152 -43
  18. package/build/storage/docs.js +14 -7
  19. package/build/storage/events.js +31 -0
  20. package/build/storage/fs-utils.js +42 -0
  21. package/build/storage/global-messages.js +44 -0
  22. package/build/storage/index.js +44 -41
  23. package/build/storage/migrations.js +79 -0
  24. package/build/storage/paths.js +0 -3
  25. package/build/storage/projects.js +4 -5
  26. package/build/storage/registry.js +67 -25
  27. package/build/storage/sqlite-meeting.js +35 -23
  28. package/build/storage/sqlite.js +41 -24
  29. package/build/storage/tasks.js +29 -103
  30. package/build/types.js +1 -1
  31. package/docs/ARCHITECTURE.md +29 -20
  32. package/docs/ARCHITECTURE_zh.md +29 -20
  33. package/docs/ASSISTANT_GUIDE.md +70 -67
  34. package/docs/README_zh.md +25 -15
  35. package/llms.txt +11 -1
  36. package/package.json +24 -14
  37. package/build/storage/logs.js +0 -60
  38. package/build/storage/meetings.js +0 -276
  39. package/build/storage/store.js +0 -153
@@ -1,54 +1,97 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
- import { NexusClient } from "../client/nexus-client.js";
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
- constructor() {
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
- const instanceId = process.env.NEXUS_INSTANCE_ID ||
18
- process.argv.find((_, i) => process.argv[i - 1] === "--id") ||
19
- `mcp-${Math.random().toString(36).slice(2, 6)}`;
20
- this.instanceId = instanceId;
21
- this.client = new NexusClient({ endpoint, timeoutMs: 5000 });
22
- this.server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { tools: { listChanged: true } } });
23
- this.setupHandlers();
39
+ this.retryIntervalMs = options.retryIntervalMs ?? RETRY_INTERVAL_MS;
40
+ this.client = new NexusClient({ endpoint, timeoutMs: options.requestTimeoutMs ?? 5000 });
24
41
  }
25
- setupHandlers() {
26
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
- tools: this.cachedTools
28
- }));
29
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
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, this.instanceId);
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
- type: "text",
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
- // Daemon went away — start retry loop
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.server.sendToolListChanged();
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.server.sendToolListChanged();
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.retryTimer)
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
- this.retryTimer = setInterval(async () => {
79
- console.error(`[n2n-nexus] Waiting for daemon at ${endpoint}...`);
80
- const ok = await this.tryConnect();
81
- if (ok && this.retryTimer) {
82
- clearInterval(this.retryTimer);
83
- this.retryTimer = null;
84
- }
85
- }, RETRY_INTERVAL_MS);
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
- async run() {
88
- const transport = new StdioServerTransport();
89
- await this.server.connect(transport);
90
- // Try to connect immediately; if not ready, start retry loop
91
- const ok = await this.tryConnect();
92
- if (!ok) {
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 ${RETRY_INTERVAL_MS / 1000}s...`);
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
  }
@@ -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 fs.writeFile(NexusPaths.docsIndexFile, JSON.stringify({}, null, 2), FILE_ENCODING);
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 fs.writeFile(NexusPaths.docsIndexFile, JSON.stringify(index, null, 2), FILE_ENCODING);
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.access(NexusPaths.globalBlueprint);
38
+ return await fs.readFile(NexusPaths.globalBlueprint, FILE_ENCODING);
35
39
  }
36
40
  catch {
37
- await fs.writeFile(NexusPaths.globalBlueprint, "# Global Collaboration Blueprint\n\nShared meeting space.", FILE_ENCODING);
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 fs.writeFile(filePath, content, FILE_ENCODING);
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
+ }
@@ -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 fs.writeFile(this.globalBlueprint, "# Global Coordination Blueprint\n\nShared meeting space.");
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 fs.writeFile(filePath, JSON.stringify(defaultValue, null, 2), FILE_ENCODING);
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 fs.writeFile(filePath, JSON.stringify(defaultValue, null, 2), FILE_ENCODING);
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
- static patchProjectManifest(id, patch) { return RegistryStorage.patchProjectManifest(id, patch); }
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 async deleteProject(id) {
78
- await ProjectStorage.deleteProject(id);
79
- await RegistryStorage.deleteProject(id);
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 async renameProject(oldId, newId) {
82
- await RegistryStorage.renameProject(oldId, newId);
83
- try {
84
- const result = await ProjectStorage.renameProject(oldId, newId);
85
- if (result === 0) {
86
- await RegistryStorage.renameProject(newId, oldId);
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
- return result;
89
- }
90
- catch (error) {
91
- await RegistryStorage.renameProject(newId, oldId).catch(() => { });
92
- throw error;
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
+ }