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.
- package/CHANGELOG.md +114 -0
- package/README.md +77 -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 +44 -21
- 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 +422 -545
- package/build/daemon/tool-outputs.js +177 -0
- package/build/daemon/tools.js +321 -0
- package/build/index.js +1 -1
- package/build/server/instance-id.js +19 -0
- package/build/server/nexus.js +243 -47
- package/build/storage/direct-messages.js +89 -0
- 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 +47 -41
- package/build/storage/migrations.js +86 -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/sessions.js +77 -0
- package/build/storage/sqlite-meeting.js +35 -23
- package/build/storage/sqlite.js +73 -24
- package/build/storage/tasks.js +29 -103
- package/build/types.js +1 -1
- package/docs/ARCHITECTURE.md +36 -25
- package/docs/ARCHITECTURE_zh.md +37 -26
- package/docs/ASSISTANT_GUIDE.md +77 -65
- package/docs/README_zh.md +34 -19
- package/llms.txt +12 -2
- 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,97 +1,293 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
48
|
-
if (
|
|
49
|
-
this.
|
|
50
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 ${
|
|
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
|
+
}
|
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
|
+
}
|