n2n-nexus 0.4.2 → 0.7.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 (41) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +77 -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 +44 -21
  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 +422 -545
  13. package/build/daemon/tool-outputs.js +177 -0
  14. package/build/daemon/tools.js +321 -0
  15. package/build/index.js +1 -1
  16. package/build/server/instance-id.js +19 -0
  17. package/build/server/nexus.js +243 -47
  18. package/build/storage/direct-messages.js +89 -0
  19. package/build/storage/docs.js +14 -7
  20. package/build/storage/events.js +31 -0
  21. package/build/storage/fs-utils.js +42 -0
  22. package/build/storage/global-messages.js +44 -0
  23. package/build/storage/index.js +47 -41
  24. package/build/storage/migrations.js +86 -0
  25. package/build/storage/paths.js +0 -3
  26. package/build/storage/projects.js +4 -5
  27. package/build/storage/registry.js +67 -25
  28. package/build/storage/sessions.js +77 -0
  29. package/build/storage/sqlite-meeting.js +35 -23
  30. package/build/storage/sqlite.js +73 -24
  31. package/build/storage/tasks.js +29 -103
  32. package/build/types.js +1 -1
  33. package/docs/ARCHITECTURE.md +36 -25
  34. package/docs/ARCHITECTURE_zh.md +37 -26
  35. package/docs/ASSISTANT_GUIDE.md +77 -65
  36. package/docs/README_zh.md +34 -19
  37. package/llms.txt +12 -2
  38. package/package.json +24 -14
  39. package/build/storage/logs.js +0 -60
  40. package/build/storage/meetings.js +0 -276
  41. package/build/storage/store.js +0 -153
@@ -1,97 +1,293 @@
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 { randomUUID } from "node:crypto";
2
+ import { CLIENT_INFO_META_KEY, ProtocolError, ProtocolErrorCode, Server, } from "@modelcontextprotocol/server";
3
+ import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
4
+ import { NexusClient, NexusToolError, NexusTransportError } from "../client/nexus-client.js";
5
5
  import { pkg } from "../config/index.js";
6
+ import { resolveInstanceId } from "./instance-id.js";
6
7
  const RETRY_INTERVAL_MS = 3000;
8
+ const HEARTBEAT_INTERVAL_MS = 20_000;
9
+ /** How long the first tools/list waits for the startup daemon fetch before answering with what it has. */
10
+ const INITIAL_LIST_WAIT_MS = 1000;
7
11
  const SERVICE_NAME = "n2n-nexus";
12
+ /**
13
+ * MCP adapter in front of the daemon, with one addressable session per connection.
14
+ *
15
+ * Served through the v2 SDK's serveStdio entry, which answers both
16
+ * protocol eras on one connection: 2026-07-28 clients (per-request `_meta`
17
+ * envelope, `server/discover`, `subscriptions/listen`) and 2025-era clients
18
+ * (`initialize` handshake).
19
+ */
8
20
  export class NexusServer {
9
- server;
21
+ server = null;
22
+ handle = null;
10
23
  client;
11
- instanceId;
12
24
  cachedTools = [];
13
25
  retryTimer = null;
26
+ /** True while a reconnect loop is active (waiting or attempting). */
27
+ retrying = false;
28
+ retryIntervalMs;
29
+ heartbeatIntervalMs;
30
+ sessionId = randomUUID();
31
+ instanceId;
32
+ workspace = process.cwd();
33
+ heartbeatTimer = null;
34
+ registration = null;
35
+ registrationAttempted = false;
36
+ shutdownPromise = null;
14
37
  connected = false;
15
- constructor() {
38
+ stopped = false;
39
+ /**
40
+ * First client name seen on this stdio connection. clientInfo is only a
41
+ * SHOULD per request under 2026-07-28, so a request without it must not
42
+ * fall back to a different instance id than its siblings.
43
+ */
44
+ clientName;
45
+ /** The first daemon fetch, started once serving has begun. */
46
+ initialFetch = null;
47
+ constructor(options = {}) {
16
48
  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();
49
+ this.retryIntervalMs = options.retryIntervalMs ?? RETRY_INTERVAL_MS;
50
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
51
+ this.client = new NexusClient({ endpoint, timeoutMs: options.requestTimeoutMs ?? 5000 });
24
52
  }
25
- setupHandlers() {
26
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
- tools: this.cachedTools
28
- }));
29
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
53
+ /** Builds the MCP server instance serveStdio pins for the connection. */
54
+ buildServer() {
55
+ const server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { tools: { listChanged: true } } });
56
+ server.setRequestHandler("tools/list", async (_request, ctx) => {
57
+ this.identifyClient(ctx.mcpReq.envelope, server);
58
+ // Give a local daemon a moment so the first list is complete; never block on a hung one.
59
+ if (!this.connected && this.initialFetch) {
60
+ await Promise.race([this.initialFetch, new Promise((r) => setTimeout(r, INITIAL_LIST_WAIT_MS))]);
61
+ }
62
+ if (this.connected) {
63
+ try {
64
+ await this.registerSession();
65
+ }
66
+ catch (e) {
67
+ if (e instanceof NexusTransportError)
68
+ await this.daemonUnavailable();
69
+ else
70
+ throw e;
71
+ }
72
+ }
73
+ return { tools: this.cachedTools };
74
+ });
75
+ server.setRequestHandler("tools/call", async (request, ctx) => {
76
+ this.identifyClient(ctx.mcpReq.envelope, server);
30
77
  if (!this.connected) {
31
78
  return {
32
79
  content: [{ type: "text", text: "Daemon is not ready yet. Please wait a moment and retry." }],
33
80
  isError: true
34
81
  };
35
82
  }
83
+ // Unknown tool is a protocol error (JSON-RPC -32602), not a tool execution error.
84
+ const unknownTool = () => new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
85
+ if (!this.cachedTools.some((t) => t.name === request.params.name))
86
+ throw unknownTool();
87
+ let callingTool = false;
36
88
  try {
37
- const result = await this.client.callTool(request.params.name, request.params.arguments, this.instanceId);
89
+ await this.registerSession();
90
+ if (this.stopped)
91
+ throw new NexusTransportError("MCP connection is closed");
92
+ callingTool = true;
93
+ const result = await this.client.callTool(request.params.name, request.params.arguments, this.instanceId, this.sessionId);
94
+ // Every daemon result is a JSON object, so it goes out as structuredContent
95
+ // (data tools also publish an outputSchema for it; write tools do not). The
96
+ // JSON text keeps clients that only read content working (the spec's
97
+ // backwards-compatibility advice).
38
98
  return {
39
- content: [{
40
- type: "text",
41
- text: typeof result === "string" ? result : JSON.stringify(result, null, 2)
42
- }]
99
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
100
+ structuredContent: result
43
101
  };
44
102
  }
45
103
  catch (e) {
46
104
  const message = e instanceof Error ? e.message : String(e);
47
- // Daemon went away — start retry loop
48
- if (this.connected) {
49
- this.connected = false;
50
- this.cachedTools = [];
51
- await this.server.sendToolListChanged();
52
- this.scheduleRetry();
105
+ // The daemon dropped the tool since the list was fetched: resync so tools/list stops offering it.
106
+ if (callingTool && e instanceof NexusToolError && e.status === 404) {
107
+ await this.refreshTools();
108
+ throw unknownTool();
53
109
  }
110
+ if (!(e instanceof NexusTransportError)) {
111
+ // The daemon answered; the tool itself failed. Keep the tool list.
112
+ return { content: [{ type: "text", text: message }], isError: true };
113
+ }
114
+ // Daemon went away — drop the tool list and start the retry loop
115
+ await this.daemonUnavailable();
54
116
  return {
55
117
  content: [{ type: "text", text: `Daemon unavailable: ${message}. Reconnecting...` }],
56
118
  isError: true
57
119
  };
58
120
  }
59
121
  });
122
+ this.server = server;
123
+ return server;
124
+ }
125
+ identifyClient(meta, server) {
126
+ if (this.instanceId)
127
+ return;
128
+ const envelope = meta;
129
+ this.clientName = envelope?.[CLIENT_INFO_META_KEY]?.name
130
+ ?? server.getClientVersion()?.name;
131
+ this.instanceId = resolveInstanceId(process.env, process.argv, this.workspace, this.clientName);
132
+ }
133
+ async daemonUnavailable() {
134
+ if (this.connected) {
135
+ this.connected = false;
136
+ this.cachedTools = [];
137
+ await this.notifyToolsChanged();
138
+ }
139
+ this.scheduleRetry();
140
+ }
141
+ /** Coalesce requests so heartbeats and tool calls cannot stack registrations. */
142
+ registerSession() {
143
+ if (this.stopped || !this.instanceId)
144
+ return Promise.resolve();
145
+ if (this.registration)
146
+ return this.registration;
147
+ this.registrationAttempted = true;
148
+ this.registration = this.client.registerSession({
149
+ sessionId: this.sessionId, instanceId: this.instanceId,
150
+ clientName: this.clientName, workspace: this.workspace
151
+ }).finally(() => {
152
+ this.registration = null;
153
+ this.scheduleHeartbeat();
154
+ });
155
+ return this.registration;
156
+ }
157
+ scheduleHeartbeat() {
158
+ if (this.stopped || this.heartbeatTimer)
159
+ return;
160
+ this.heartbeatTimer = setTimeout(async () => {
161
+ this.heartbeatTimer = null;
162
+ try {
163
+ await this.registerSession();
164
+ }
165
+ catch (e) {
166
+ if (e instanceof NexusTransportError)
167
+ await this.daemonUnavailable();
168
+ }
169
+ }, this.heartbeatIntervalMs);
170
+ }
171
+ /** Delivered unsolicited on 2025-era connections and via subscriptions/listen on 2026-07-28 ones. */
172
+ async notifyToolsChanged() {
173
+ await this.server?.sendToolListChanged().catch(() => { });
174
+ }
175
+ /** Re-fetch the daemon's tool list; notify the client if it changed. */
176
+ async refreshTools() {
177
+ try {
178
+ const tools = await this.client.fetchTools();
179
+ const before = JSON.stringify(this.cachedTools);
180
+ this.cachedTools = tools;
181
+ if (JSON.stringify(tools) !== before)
182
+ await this.notifyToolsChanged();
183
+ }
184
+ catch {
185
+ // Transport trouble is handled by the next call; keep the current list.
186
+ }
60
187
  }
61
188
  async tryConnect() {
62
189
  try {
63
190
  const tools = await this.client.fetchTools();
191
+ if (this.stopped)
192
+ return false;
193
+ if (this.instanceId)
194
+ await this.registerSession();
195
+ if (this.stopped)
196
+ return false;
197
+ // JSON Schema produced by the daemon; the SDK models it as JSON values.
64
198
  this.cachedTools = tools;
65
199
  this.connected = true;
66
200
  console.error(`[n2n-nexus] Connected to daemon. ${tools.length} tools loaded.`);
67
- await this.server.sendToolListChanged();
201
+ await this.notifyToolsChanged();
68
202
  return true;
69
203
  }
70
204
  catch {
71
205
  return false;
72
206
  }
73
207
  }
208
+ stopRetry() {
209
+ this.retrying = false;
210
+ if (this.retryTimer) {
211
+ clearTimeout(this.retryTimer);
212
+ this.retryTimer = null;
213
+ }
214
+ }
215
+ /** Stop reconnecting and close the MCP connection. Safe to call more than once. */
216
+ shutdown() {
217
+ if (this.shutdownPromise)
218
+ return this.shutdownPromise;
219
+ this.stopped = true;
220
+ this.stopRetry();
221
+ if (this.heartbeatTimer)
222
+ clearTimeout(this.heartbeatTimer);
223
+ this.heartbeatTimer = null;
224
+ this.shutdownPromise = (async () => {
225
+ await this.registration?.catch(() => { });
226
+ if (this.registrationAttempted && this.instanceId) {
227
+ await this.client.disconnectSession(this.sessionId, this.instanceId).catch(() => { });
228
+ }
229
+ await this.handle?.close().catch(() => { });
230
+ })();
231
+ return this.shutdownPromise;
232
+ }
233
+ /**
234
+ * Reconnect loop. Each attempt is scheduled only after the previous one
235
+ * finished, so a daemon that hangs (attempt longer than the interval)
236
+ * never accumulates concurrent requests.
237
+ */
74
238
  scheduleRetry() {
75
- if (this.retryTimer)
239
+ if (this.retrying || this.stopped)
76
240
  return;
241
+ this.retrying = true;
77
242
  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);
243
+ const next = () => {
244
+ this.retryTimer = setTimeout(async () => {
245
+ console.error(`[n2n-nexus] Waiting for daemon at ${endpoint}...`);
246
+ const ok = await this.tryConnect();
247
+ if (!this.retrying)
248
+ return; // stopped while the attempt was in flight
249
+ // A tools/call may have lost the daemon again while this attempt was still
250
+ // notifying the client; only stop if the connection is still up.
251
+ if (ok && this.connected)
252
+ this.stopRetry();
253
+ else
254
+ next();
255
+ }, this.retryIntervalMs);
256
+ };
257
+ next();
86
258
  }
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) {
259
+ /**
260
+ * Serve MCP. Per the stdio transport spec the server should exit when its
261
+ * input closes; without that the reconnect timer would keep the adapter
262
+ * alive after the IDE went away.
263
+ */
264
+ async run(options = {}) {
265
+ const stdio = !options.transport;
266
+ const input = options.input ?? (stdio ? process.stdin : undefined);
267
+ const onExit = options.onExit ?? (() => process.exit(0));
268
+ if (input) {
269
+ const exit = () => { void this.shutdown().then(onExit); };
270
+ input.once("end", exit);
271
+ input.once("close", exit);
272
+ }
273
+ // Serve first so discover/initialize are answered even when the daemon hangs;
274
+ // tools/list briefly waits for this fetch (2026-07-28 clients only hear
275
+ // list_changed if they subscribe).
276
+ const transport = options.transport ?? new StdioServerTransport();
277
+ this.handle = serveStdio(() => this.buildServer(), {
278
+ transport,
279
+ onerror: (err) => console.error("[n2n-nexus] MCP transport error:", err.message)
280
+ });
281
+ const onclose = transport.onclose;
282
+ transport.onclose = () => {
283
+ onclose?.();
284
+ void this.shutdown();
285
+ };
286
+ this.initialFetch = this.tryConnect();
287
+ const ok = await this.initialFetch;
288
+ if (!ok && !this.stopped) {
93
289
  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...`);
290
+ console.error(`[n2n-nexus] Daemon not available at ${endpoint}. Will retry every ${this.retryIntervalMs / 1000}s...`);
95
291
  this.scheduleRetry();
96
292
  }
97
293
  }
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SessionStore } from "./sessions.js";
3
+ import { getDatabase } from "./sqlite.js";
4
+ export const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60 * 1000;
5
+ function toMessage(row) {
6
+ return {
7
+ messageId: row.message_id, from: row.from_session_id, to: row.to_session_id,
8
+ text: row.text, category: row.category ?? undefined, replyTo: row.reply_to, status: row.status,
9
+ createdAt: row.created_at, expiresAt: row.expires_at, fetchedAt: row.fetched_at,
10
+ acknowledgedAt: row.acknowledged_at, repliedAt: row.replied_at, reason: row.reason
11
+ };
12
+ }
13
+ function expire(now) {
14
+ getDatabase().prepare("UPDATE direct_messages SET status = 'expired', reason = 'Message TTL elapsed' WHERE status IN ('accepted', 'fetched') AND expires_at <= ?")
15
+ .run(new Date(now).toISOString());
16
+ }
17
+ export class DirectMessageStore {
18
+ static send(fromSessionId, input, now = Date.now()) {
19
+ SessionStore.require(fromSessionId);
20
+ const ttlMs = input.ttlMs ?? DEFAULT_MESSAGE_TTL_MS;
21
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)
22
+ throw new Error("ttlMs must be a positive integer");
23
+ const db = getDatabase();
24
+ expire(now);
25
+ return db.transaction(() => {
26
+ const recipient = SessionStore.require(input.to);
27
+ if (input.replyTo) {
28
+ const original = this.get(input.replyTo, fromSessionId, now);
29
+ if (original.to !== fromSessionId || original.from !== input.to)
30
+ throw new Error("Reply must target the original sender");
31
+ if (!["fetched", "acknowledged", "replied"].includes(original.status))
32
+ throw new Error(`Cannot reply to a message with status ${original.status}`);
33
+ }
34
+ const messageId = randomUUID();
35
+ const timestamp = new Date(now).toISOString();
36
+ const status = recipient.inbound === "refuse" ? "refused" : "accepted";
37
+ db.prepare("INSERT INTO direct_messages (message_id, from_session_id, to_session_id, text, category, reply_to, status, created_at, expires_at, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
38
+ .run(messageId, fromSessionId, input.to, input.message, input.category ?? null, input.replyTo ?? null, status, timestamp, new Date(now + ttlMs).toISOString(), status === "refused" ? "Recipient refuses incoming messages" : null);
39
+ if (input.replyTo && status === "accepted") {
40
+ db.prepare("UPDATE direct_messages SET status = 'replied', replied_at = COALESCE(replied_at, ?) WHERE message_id = ?")
41
+ .run(timestamp, input.replyTo);
42
+ }
43
+ return this.get(messageId, fromSessionId, now);
44
+ })();
45
+ }
46
+ static readInbox(sessionId, count, now = Date.now()) {
47
+ SessionStore.require(sessionId);
48
+ const db = getDatabase();
49
+ return db.transaction(() => {
50
+ expire(now);
51
+ const rows = db.prepare("SELECT * FROM direct_messages WHERE to_session_id = ? AND status IN ('accepted', 'fetched') ORDER BY created_at ASC, rowid ASC LIMIT ?")
52
+ .all(sessionId, count);
53
+ const timestamp = new Date(now).toISOString();
54
+ const update = db.prepare("UPDATE direct_messages SET status = 'fetched', fetched_at = ? WHERE message_id = ? AND status = 'accepted'");
55
+ return rows.map(row => {
56
+ if (row.status === "accepted") {
57
+ update.run(timestamp, row.message_id);
58
+ row.status = "fetched";
59
+ row.fetched_at = timestamp;
60
+ }
61
+ return toMessage(row);
62
+ });
63
+ })();
64
+ }
65
+ static get(messageId, actorSessionId, now = Date.now()) {
66
+ expire(now);
67
+ const row = getDatabase().prepare("SELECT * FROM direct_messages WHERE message_id = ? AND (from_session_id = ? OR to_session_id = ?)")
68
+ .get(messageId, actorSessionId, actorSessionId);
69
+ if (!row)
70
+ throw new Error("Message not found for this session");
71
+ return toMessage(row);
72
+ }
73
+ static acknowledge(messageId, recipientSessionId, outcome, now = Date.now()) {
74
+ const db = getDatabase();
75
+ expire(now);
76
+ return db.transaction(() => {
77
+ const message = this.get(messageId, recipientSessionId, now);
78
+ if (message.to !== recipientSessionId)
79
+ throw new Error("Only the recipient can acknowledge a message");
80
+ if (message.status === outcome)
81
+ return message;
82
+ if (message.status !== "fetched")
83
+ throw new Error(`Cannot ${outcome === "acknowledged" ? "acknowledge" : "refuse"} a message with status ${message.status}`);
84
+ db.prepare("UPDATE direct_messages SET status = ?, acknowledged_at = ?, reason = ? WHERE message_id = ?")
85
+ .run(outcome, outcome === "acknowledged" ? new Date(now).toISOString() : null, outcome === "refused" ? "Recipient refused message" : null, messageId);
86
+ return this.get(messageId, recipientSessionId, now);
87
+ })();
88
+ }
89
+ }
@@ -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
+ }