mono-pilot 0.2.9 → 0.2.12

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 (158) hide show
  1. package/README.md +270 -7
  2. package/dist/src/agents-paths.js +36 -0
  3. package/dist/src/brief/blocks.js +83 -0
  4. package/dist/src/brief/defaults.js +60 -0
  5. package/dist/src/brief/frontmatter.js +53 -0
  6. package/dist/src/brief/paths.js +10 -0
  7. package/dist/src/brief/reflection.js +27 -0
  8. package/dist/src/cli.js +62 -5
  9. package/dist/src/cluster/bus.js +102 -0
  10. package/dist/src/cluster/follower.js +137 -0
  11. package/dist/src/cluster/init.js +182 -0
  12. package/dist/src/cluster/leader.js +97 -0
  13. package/dist/src/cluster/log.js +49 -0
  14. package/dist/src/cluster/protocol.js +34 -0
  15. package/dist/src/cluster/services/bus.js +243 -0
  16. package/dist/src/cluster/services/embedding.js +12 -0
  17. package/dist/src/cluster/socket.js +86 -0
  18. package/dist/src/cluster/test-bus.js +175 -0
  19. package/dist/src/cluster_v2/connection-lifecycle.js +31 -0
  20. package/dist/src/cluster_v2/connection-lifecycle.test.js +24 -0
  21. package/dist/src/cluster_v2/connection.js +159 -0
  22. package/dist/src/cluster_v2/connection.test.js +55 -0
  23. package/dist/src/cluster_v2/events.js +102 -0
  24. package/dist/src/cluster_v2/index.js +2 -0
  25. package/dist/src/cluster_v2/observability.js +99 -0
  26. package/dist/src/cluster_v2/observability.test.js +46 -0
  27. package/dist/src/cluster_v2/rpc.js +389 -0
  28. package/dist/src/cluster_v2/rpc.test.js +110 -0
  29. package/dist/src/cluster_v2/runtime.failover.integration.test.js +156 -0
  30. package/dist/src/cluster_v2/runtime.js +531 -0
  31. package/dist/src/cluster_v2/runtime.lease-compromise.integration.test.js +91 -0
  32. package/dist/src/cluster_v2/runtime.lifecycle.integration.test.js +225 -0
  33. package/dist/src/cluster_v2/services/bus.integration.test.js +140 -0
  34. package/dist/src/cluster_v2/services/bus.js +450 -0
  35. package/dist/src/cluster_v2/services/discord/auth-store.js +82 -0
  36. package/dist/src/cluster_v2/services/discord/collector.js +569 -0
  37. package/dist/src/cluster_v2/services/discord/index.js +1 -0
  38. package/dist/src/cluster_v2/services/discord/oauth.js +87 -0
  39. package/dist/src/cluster_v2/services/discord/rpc-client.js +325 -0
  40. package/dist/src/cluster_v2/services/embedding.js +66 -0
  41. package/dist/src/cluster_v2/services/registry-cache.js +107 -0
  42. package/dist/src/cluster_v2/services/registry-cache.test.js +66 -0
  43. package/dist/src/cluster_v2/services/registry.js +36 -0
  44. package/dist/src/cluster_v2/services/twitter/collector.js +1055 -0
  45. package/dist/src/cluster_v2/services/twitter/index.js +1 -0
  46. package/dist/src/config/digest.js +78 -0
  47. package/dist/src/config/discord.js +143 -0
  48. package/dist/src/config/image-gen.js +48 -0
  49. package/dist/src/config/mono-pilot.js +31 -0
  50. package/dist/src/config/twitter.js +100 -0
  51. package/dist/src/extensions/cluster.js +311 -0
  52. package/dist/src/extensions/commands/build-memory.js +76 -0
  53. package/dist/src/extensions/commands/digest/backfill.js +779 -0
  54. package/dist/src/extensions/commands/digest/index.js +1133 -0
  55. package/dist/src/extensions/commands/image-model.js +214 -0
  56. package/dist/src/extensions/game/bus-injection.js +47 -0
  57. package/dist/src/extensions/game/identity.js +83 -0
  58. package/dist/src/extensions/game/mailbox.js +61 -0
  59. package/dist/src/extensions/game/system-prompt.js +134 -0
  60. package/dist/src/extensions/game/tools.js +28 -0
  61. package/dist/src/extensions/lifecycle.js +337 -0
  62. package/dist/src/extensions/mode-runtime.js +26 -2
  63. package/dist/src/extensions/mono-game.js +66 -0
  64. package/dist/src/extensions/mono-pilot.js +100 -18
  65. package/dist/src/extensions/nvim.js +47 -0
  66. package/dist/src/extensions/session-hints.js +60 -35
  67. package/dist/src/extensions/sftp.js +897 -0
  68. package/dist/src/extensions/status.js +676 -0
  69. package/dist/src/extensions/system-events.js +478 -0
  70. package/dist/src/extensions/system-prompt.js +24 -14
  71. package/dist/src/extensions/user-message.js +94 -50
  72. package/dist/src/lsp/client.js +235 -0
  73. package/dist/src/lsp/index.js +165 -0
  74. package/dist/src/lsp/runtime.js +67 -0
  75. package/dist/src/lsp/server.js +242 -0
  76. package/dist/src/mcp/config.js +112 -0
  77. package/dist/src/{utils/mcp-client.js → mcp/protocol.js} +1 -100
  78. package/dist/src/mcp/servers.js +90 -0
  79. package/dist/src/memory/build-memory.js +103 -0
  80. package/dist/src/memory/config/defaults.js +55 -0
  81. package/dist/src/memory/config/loader.js +29 -0
  82. package/dist/src/memory/config/paths.js +9 -0
  83. package/dist/src/memory/config/resolve.js +90 -0
  84. package/dist/src/memory/config/types.js +1 -0
  85. package/dist/src/memory/embeddings/batch-runner.js +39 -0
  86. package/dist/src/memory/embeddings/cache.js +47 -0
  87. package/dist/src/memory/embeddings/chunk-limits.js +26 -0
  88. package/dist/src/memory/embeddings/input-limits.js +48 -0
  89. package/dist/src/memory/embeddings/local.js +108 -0
  90. package/dist/src/memory/embeddings/types.js +1 -0
  91. package/dist/src/memory/index-manager.js +552 -0
  92. package/dist/src/memory/indexing/embeddings.js +67 -0
  93. package/dist/src/memory/indexing/files.js +180 -0
  94. package/dist/src/memory/indexing/index-file.js +105 -0
  95. package/dist/src/memory/log.js +38 -0
  96. package/dist/src/memory/paths.js +15 -0
  97. package/dist/src/memory/runtime/index.js +299 -0
  98. package/dist/src/memory/runtime/thread.js +116 -0
  99. package/dist/src/memory/search/fts.js +57 -0
  100. package/dist/src/memory/search/hybrid.js +50 -0
  101. package/dist/src/memory/search/text.js +30 -0
  102. package/dist/src/memory/search/vector.js +43 -0
  103. package/dist/src/memory/session/content-hash.js +7 -0
  104. package/dist/src/memory/session/entry.js +33 -0
  105. package/dist/src/memory/session/flush-policy.js +34 -0
  106. package/dist/src/memory/session/hook.js +191 -0
  107. package/dist/src/memory/session/paths.js +15 -0
  108. package/dist/src/memory/session/session-reader.js +88 -0
  109. package/dist/src/memory/session/transcript/content-hash.js +7 -0
  110. package/dist/src/memory/session/transcript/entry.js +28 -0
  111. package/dist/src/memory/session/transcript/flush.js +56 -0
  112. package/dist/src/memory/session/transcript/paths.js +28 -0
  113. package/dist/src/memory/session/transcript/reader.js +112 -0
  114. package/dist/src/memory/session/transcript/state.js +31 -0
  115. package/dist/src/memory/store/schema.js +89 -0
  116. package/dist/src/memory/store/sqlite.js +89 -0
  117. package/dist/src/memory/types.js +1 -0
  118. package/dist/src/memory/warm.js +25 -0
  119. package/dist/src/rules/discovery.js +41 -0
  120. package/dist/{tools → src/tools}/README.md +29 -3
  121. package/dist/{tools → src/tools}/apply-patch-description.md +8 -2
  122. package/dist/{tools → src/tools}/apply-patch.js +174 -104
  123. package/dist/{tools → src/tools}/apply-patch.test.js +52 -1
  124. package/dist/{tools/ask-question.js → src/tools/ask-user-question.js} +3 -3
  125. package/dist/src/tools/ast-grep.js +357 -0
  126. package/dist/src/tools/brief-write.js +122 -0
  127. package/dist/src/tools/bus-send.js +100 -0
  128. package/dist/{tools → src/tools}/call-mcp-tool.js +40 -124
  129. package/dist/src/tools/codex-apply-patch-description.md +52 -0
  130. package/dist/src/tools/codex-apply-patch.js +540 -0
  131. package/dist/{tools → src/tools}/delete.js +24 -0
  132. package/dist/src/tools/exit-plan-mode.js +83 -0
  133. package/dist/{tools → src/tools}/fetch-mcp-resource.js +56 -100
  134. package/dist/src/tools/generate-image.js +567 -0
  135. package/dist/{tools → src/tools}/glob.js +55 -1
  136. package/dist/{tools → src/tools}/list-mcp-resources.js +46 -57
  137. package/dist/{tools → src/tools}/list-mcp-tools.js +52 -63
  138. package/dist/src/tools/ls.js +48 -0
  139. package/dist/src/tools/lsp-diagnostics.js +67 -0
  140. package/dist/src/tools/lsp-symbols.js +54 -0
  141. package/dist/src/tools/mailbox.js +85 -0
  142. package/dist/src/tools/memory-get.js +90 -0
  143. package/dist/src/tools/memory-search.js +180 -0
  144. package/dist/{tools → src/tools}/plan-mode-reminder.md +3 -4
  145. package/dist/{tools → src/tools}/read-file.js +8 -19
  146. package/dist/{tools → src/tools}/rg.js +10 -20
  147. package/dist/{tools → src/tools}/shell.js +19 -42
  148. package/dist/{tools → src/tools}/subagent.js +255 -6
  149. package/dist/{tools → src/tools}/switch-mode.js +37 -6
  150. package/dist/{tools → src/tools}/web-fetch.js +105 -7
  151. package/dist/{tools → src/tools}/web-search.js +29 -1
  152. package/package.json +21 -9
  153. /package/dist/{tools → src/tools}/ask-mode-reminder.md +0 -0
  154. /package/dist/{tools → src/tools}/rg.test.js +0 -0
  155. /package/dist/{tools → src/tools}/semantic-search-description.md +0 -0
  156. /package/dist/{tools → src/tools}/semantic-search.js +0 -0
  157. /package/dist/{tools → src/tools}/shell-description.md +0 -0
  158. /package/dist/{tools → src/tools}/subagent-description.md +0 -0
@@ -0,0 +1,325 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import net from "node:net";
3
+ import { EventEmitter } from "node:events";
4
+ const OPCODE_HANDSHAKE = 0;
5
+ const OPCODE_FRAME = 1;
6
+ const OPCODE_CLOSE = 2;
7
+ const OPCODE_PING = 3;
8
+ const OPCODE_PONG = 4;
9
+ const DEFAULT_MAX_IPC_ID = 10;
10
+ const DEFAULT_CONNECT_TIMEOUT_MS = 3_000;
11
+ const MAX_FRAME_BYTES = 8 * 1024 * 1024;
12
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
13
+ const AUTHORIZE_TIMEOUT_MS = 120_000;
14
+ const READY_TIMEOUT_MS = 10_000;
15
+ function getIpcPath(id) {
16
+ if (process.platform === "win32") {
17
+ return `\\\\?\\pipe\\discord-ipc-${id}`;
18
+ }
19
+ const { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP } = process.env;
20
+ const prefix = XDG_RUNTIME_DIR || TMPDIR || TMP || TEMP || "/tmp";
21
+ return `${prefix.replace(/\/$/, "")}/discord-ipc-${id}`;
22
+ }
23
+ function encodeFrame(op, payload) {
24
+ const json = Buffer.from(JSON.stringify(payload), "utf8");
25
+ const packet = Buffer.alloc(8 + json.length);
26
+ packet.writeInt32LE(op, 0);
27
+ packet.writeInt32LE(json.length, 4);
28
+ json.copy(packet, 8);
29
+ return packet;
30
+ }
31
+ class FrameDecoder {
32
+ buffer = Buffer.alloc(0);
33
+ feed(chunk) {
34
+ this.buffer = Buffer.concat([this.buffer, chunk]);
35
+ const frames = [];
36
+ while (this.buffer.length >= 8) {
37
+ const op = this.buffer.readInt32LE(0);
38
+ const len = this.buffer.readInt32LE(4);
39
+ if (len < 0 || len > MAX_FRAME_BYTES) {
40
+ throw new Error(`invalid discord ipc frame length: ${len}`);
41
+ }
42
+ if (this.buffer.length < 8 + len) {
43
+ break;
44
+ }
45
+ const json = this.buffer.subarray(8, 8 + len).toString("utf8");
46
+ this.buffer = this.buffer.subarray(8 + len);
47
+ frames.push({ op, data: JSON.parse(json) });
48
+ }
49
+ return frames;
50
+ }
51
+ }
52
+ async function connectSocket(options) {
53
+ const maxIpcId = options?.maxIpcId ?? DEFAULT_MAX_IPC_ID;
54
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
55
+ let lastError = null;
56
+ for (let id = 0; id <= maxIpcId; id += 1) {
57
+ const socketPath = getIpcPath(id);
58
+ try {
59
+ const socket = await new Promise((resolve, reject) => {
60
+ const socket = net.createConnection(socketPath);
61
+ const timer = setTimeout(() => {
62
+ socket.destroy();
63
+ reject(new Error(`discord ipc connect timeout: ${socketPath}`));
64
+ }, timeoutMs);
65
+ socket.once("connect", () => {
66
+ clearTimeout(timer);
67
+ resolve(socket);
68
+ });
69
+ socket.once("error", (error) => {
70
+ clearTimeout(timer);
71
+ reject(error instanceof Error ? error : new Error(String(error)));
72
+ });
73
+ });
74
+ socket.setNoDelay(true);
75
+ return socket;
76
+ }
77
+ catch (error) {
78
+ lastError = error instanceof Error ? error : new Error(String(error));
79
+ }
80
+ }
81
+ throw lastError ?? new Error("unable to connect to discord ipc socket");
82
+ }
83
+ function isRecord(value) {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+ function readString(value) {
87
+ return typeof value === "string" && value.length > 0 ? value : null;
88
+ }
89
+ export class DiscordRpcClient {
90
+ options;
91
+ socket = null;
92
+ decoder = new FrameDecoder();
93
+ pending = new Map();
94
+ emitter = new EventEmitter();
95
+ ready = false;
96
+ readyWaiters = new Set();
97
+ constructor(options) {
98
+ this.options = options;
99
+ }
100
+ async connect() {
101
+ if (this.socket && !this.socket.destroyed) {
102
+ if (!this.ready) {
103
+ await this.waitForReady();
104
+ }
105
+ return;
106
+ }
107
+ const socket = await connectSocket(this.options.connect);
108
+ this.socket = socket;
109
+ this.ready = false;
110
+ socket.on("data", (chunk) => {
111
+ try {
112
+ const frames = this.decoder.feed(chunk);
113
+ for (const frame of frames) {
114
+ this.handleFrame(frame);
115
+ }
116
+ }
117
+ catch (error) {
118
+ this.failPending(error instanceof Error ? error : new Error(String(error)));
119
+ this.shutdownSocket();
120
+ }
121
+ });
122
+ socket.on("error", (error) => {
123
+ this.failPending(error instanceof Error ? error : new Error(String(error)));
124
+ this.shutdownSocket();
125
+ });
126
+ socket.on("close", () => {
127
+ this.failPending(new Error("discord ipc socket closed"));
128
+ this.shutdownSocket();
129
+ });
130
+ socket.write(encodeFrame(OPCODE_HANDSHAKE, {
131
+ v: 1,
132
+ client_id: this.options.clientId,
133
+ }));
134
+ await this.waitForReady();
135
+ }
136
+ async authenticate(accessToken) {
137
+ await this.request("AUTHENTICATE", { access_token: accessToken });
138
+ }
139
+ async authorize(scopes, options) {
140
+ const result = await this.request("AUTHORIZE", {
141
+ client_id: this.options.clientId,
142
+ scopes,
143
+ prompt: options?.prompt,
144
+ rpc_token: options?.rpcToken,
145
+ }, undefined, AUTHORIZE_TIMEOUT_MS);
146
+ if (!isRecord(result) || typeof result.code !== "string" || result.code.length === 0) {
147
+ throw new Error("discord rpc authorize did not return code");
148
+ }
149
+ return { code: result.code };
150
+ }
151
+ async subscribe(eventName, args) {
152
+ await this.request("SUBSCRIBE", args ?? {}, eventName);
153
+ }
154
+ async getChannel(channelId) {
155
+ const result = await this.request("GET_CHANNEL", { channel_id: channelId });
156
+ if (!isRecord(result)) {
157
+ throw new Error("discord rpc GET_CHANNEL returned invalid payload");
158
+ }
159
+ const id = readString(result.id);
160
+ if (!id) {
161
+ throw new Error("discord rpc GET_CHANNEL missing channel id");
162
+ }
163
+ return {
164
+ id,
165
+ name: readString(result.name),
166
+ guildId: readString(result.guild_id),
167
+ };
168
+ }
169
+ async getGuild(guildId) {
170
+ const result = await this.request("GET_GUILD", { guild_id: guildId });
171
+ if (!isRecord(result)) {
172
+ throw new Error("discord rpc GET_GUILD returned invalid payload");
173
+ }
174
+ const id = readString(result.id);
175
+ if (!id) {
176
+ throw new Error("discord rpc GET_GUILD missing guild id");
177
+ }
178
+ return {
179
+ id,
180
+ name: readString(result.name),
181
+ };
182
+ }
183
+ onEvent(eventName, handler) {
184
+ this.emitter.on(`event:${eventName}`, handler);
185
+ return () => {
186
+ this.emitter.off(`event:${eventName}`, handler);
187
+ };
188
+ }
189
+ onDisconnected(handler) {
190
+ this.emitter.on("disconnected", handler);
191
+ return () => {
192
+ this.emitter.off("disconnected", handler);
193
+ };
194
+ }
195
+ async waitForDisconnect() {
196
+ if (!this.socket || this.socket.destroyed) {
197
+ return;
198
+ }
199
+ await new Promise((resolve) => {
200
+ const off = this.onDisconnected(() => {
201
+ off();
202
+ resolve();
203
+ });
204
+ });
205
+ }
206
+ close() {
207
+ this.failPending(new Error("discord rpc client closed"));
208
+ this.shutdownSocket();
209
+ }
210
+ async request(cmd, args, evt, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
211
+ const socket = this.socket;
212
+ if (!socket || socket.destroyed) {
213
+ throw new Error("discord ipc socket is not connected");
214
+ }
215
+ const nonce = randomUUID();
216
+ return new Promise((resolve, reject) => {
217
+ const timer = setTimeout(() => {
218
+ this.pending.delete(nonce);
219
+ reject(new Error(`discord rpc timeout: ${cmd}`));
220
+ }, timeoutMs);
221
+ this.pending.set(nonce, { resolve, reject, timer });
222
+ socket.write(encodeFrame(OPCODE_FRAME, {
223
+ cmd,
224
+ args,
225
+ evt,
226
+ nonce,
227
+ }));
228
+ });
229
+ }
230
+ handleFrame(frame) {
231
+ if (frame.op === OPCODE_PING) {
232
+ this.socket?.write(encodeFrame(OPCODE_PONG, frame.data));
233
+ return;
234
+ }
235
+ if (frame.op === OPCODE_CLOSE) {
236
+ this.shutdownSocket();
237
+ return;
238
+ }
239
+ if (frame.op !== OPCODE_FRAME || !isRecord(frame.data)) {
240
+ return;
241
+ }
242
+ const envelope = frame.data;
243
+ const nonce = typeof envelope.nonce === "string" ? envelope.nonce : undefined;
244
+ if (nonce && this.pending.has(nonce)) {
245
+ const pending = this.pending.get(nonce);
246
+ if (!pending) {
247
+ return;
248
+ }
249
+ this.pending.delete(nonce);
250
+ clearTimeout(pending.timer);
251
+ if (envelope.evt === "ERROR") {
252
+ const message = isRecord(envelope.data) && typeof envelope.data.message === "string"
253
+ ? envelope.data.message
254
+ : "discord rpc command failed";
255
+ pending.reject(new Error(message));
256
+ return;
257
+ }
258
+ pending.resolve(envelope.data);
259
+ return;
260
+ }
261
+ if (envelope.cmd === "DISPATCH" && typeof envelope.evt === "string") {
262
+ if (envelope.evt === "READY") {
263
+ this.markReady();
264
+ }
265
+ this.emitter.emit(`event:${envelope.evt}`, envelope.data);
266
+ }
267
+ }
268
+ waitForReady(timeoutMs = READY_TIMEOUT_MS) {
269
+ if (this.ready) {
270
+ return Promise.resolve();
271
+ }
272
+ return new Promise((resolve, reject) => {
273
+ const waiter = {
274
+ resolve: () => {
275
+ clearTimeout(waiter.timer);
276
+ this.readyWaiters.delete(waiter);
277
+ resolve();
278
+ },
279
+ reject: (error) => {
280
+ clearTimeout(waiter.timer);
281
+ this.readyWaiters.delete(waiter);
282
+ reject(error);
283
+ },
284
+ timer: setTimeout(() => {
285
+ this.readyWaiters.delete(waiter);
286
+ reject(new Error("discord rpc timeout: READY"));
287
+ }, timeoutMs),
288
+ };
289
+ this.readyWaiters.add(waiter);
290
+ });
291
+ }
292
+ markReady() {
293
+ if (this.ready) {
294
+ return;
295
+ }
296
+ this.ready = true;
297
+ for (const waiter of this.readyWaiters) {
298
+ waiter.resolve();
299
+ }
300
+ this.readyWaiters.clear();
301
+ }
302
+ failPending(error) {
303
+ for (const [nonce, pending] of this.pending.entries()) {
304
+ this.pending.delete(nonce);
305
+ clearTimeout(pending.timer);
306
+ pending.reject(error);
307
+ }
308
+ }
309
+ shutdownSocket() {
310
+ const socket = this.socket;
311
+ this.ready = false;
312
+ for (const waiter of this.readyWaiters) {
313
+ waiter.reject(new Error("discord ipc socket closed before READY"));
314
+ }
315
+ this.readyWaiters.clear();
316
+ if (!socket) {
317
+ return;
318
+ }
319
+ this.socket = null;
320
+ if (!socket.destroyed) {
321
+ socket.destroy();
322
+ }
323
+ this.emitter.emit("disconnected");
324
+ }
325
+ }
@@ -0,0 +1,66 @@
1
+ export const EMBEDDING_METHOD_EMBED_BATCH = "embedding.embedBatch";
2
+ const DEFAULT_MAX_CONCURRENT_REQUESTS = 4;
3
+ const DEFAULT_MAX_TEXTS_PER_REQUEST = 16;
4
+ function toPositiveInteger(value, fallback) {
5
+ if (typeof value !== "number" || !Number.isFinite(value)) {
6
+ return fallback;
7
+ }
8
+ const normalized = Math.floor(value);
9
+ if (normalized <= 0) {
10
+ return fallback;
11
+ }
12
+ return normalized;
13
+ }
14
+ function parseEmbedBatchParams(params) {
15
+ if (!params || typeof params !== "object") {
16
+ throw new Error("embedding.embedBatch requires object params");
17
+ }
18
+ const texts = params.texts;
19
+ if (!Array.isArray(texts) || texts.some((item) => typeof item !== "string")) {
20
+ throw new Error("embedding.embedBatch requires string[] texts");
21
+ }
22
+ return { texts };
23
+ }
24
+ export function createEmbeddingHandlers(provider, options) {
25
+ const maxConcurrentRequests = toPositiveInteger(options?.maxConcurrentRequests, DEFAULT_MAX_CONCURRENT_REQUESTS);
26
+ const maxTextsPerRequest = toPositiveInteger(options?.maxTextsPerRequest, DEFAULT_MAX_TEXTS_PER_REQUEST);
27
+ let inFlight = 0;
28
+ return {
29
+ [EMBEDDING_METHOD_EMBED_BATCH]: async (request) => {
30
+ const { texts } = parseEmbedBatchParams(request.params);
31
+ if (texts.length > maxTextsPerRequest) {
32
+ throw new Error(`embedding.embedBatch exceeded text limit ${maxTextsPerRequest}: got ${texts.length}`);
33
+ }
34
+ if (inFlight >= maxConcurrentRequests) {
35
+ throw new Error(`embedding service overloaded: in-flight limit ${maxConcurrentRequests} reached`);
36
+ }
37
+ inFlight += 1;
38
+ try {
39
+ const vectors = await provider.embedBatch(texts);
40
+ const result = { vectors };
41
+ return result;
42
+ }
43
+ finally {
44
+ inFlight = Math.max(0, inFlight - 1);
45
+ }
46
+ },
47
+ };
48
+ }
49
+ export function createEmbeddingClient(client, options) {
50
+ const timeoutMs = options.timeoutMs ?? 30_000;
51
+ return {
52
+ id: "local",
53
+ model: options.model,
54
+ embedQuery: async (text) => {
55
+ const result = await client.call(EMBEDDING_METHOD_EMBED_BATCH, { texts: [text] }, { timeoutMs });
56
+ return result.vectors[0] ?? [];
57
+ },
58
+ embedBatch: async (texts) => {
59
+ const result = await client.call(EMBEDDING_METHOD_EMBED_BATCH, { texts }, { timeoutMs });
60
+ return result.vectors;
61
+ },
62
+ dispose: async () => {
63
+ // lifecycle owns ClusterRpcClient and closes it.
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,107 @@
1
+ import { logClusterEvent } from "../observability.js";
2
+ /**
3
+ * Follower-side registry cache with revision-bound guards.
4
+ *
5
+ * Conformance invariant: cacheRevision <= registryRevision.
6
+ */
7
+ export class FollowerRegistryCache {
8
+ client;
9
+ context;
10
+ revision = 0;
11
+ services = new Map();
12
+ constructor(client, context) {
13
+ this.client = client;
14
+ this.context = context;
15
+ }
16
+ currentRevision() {
17
+ return this.revision;
18
+ }
19
+ invalidate(reason) {
20
+ if (this.revision !== 0 || this.services.size > 0) {
21
+ logClusterEvent("info", "registry_cache_invalidated", this.context, {
22
+ reason,
23
+ cacheRevision: this.revision,
24
+ cachedServices: this.services.size,
25
+ });
26
+ }
27
+ this.revision = 0;
28
+ this.services.clear();
29
+ }
30
+ async refresh() {
31
+ const snapshot = await this.client.call("registry.list", {});
32
+ this.applySnapshot(snapshot, "refresh");
33
+ return this.revision;
34
+ }
35
+ async resolve(name) {
36
+ const cached = this.services.get(name);
37
+ if (cached) {
38
+ return { ...cached };
39
+ }
40
+ const response = await this.client.call("registry.resolve", { name });
41
+ this.applyResolve(name, response);
42
+ const resolved = this.services.get(name);
43
+ return resolved ? { ...resolved } : undefined;
44
+ }
45
+ async requireService(name) {
46
+ const service = await this.resolve(name);
47
+ if (!service) {
48
+ throw new Error(`required service unavailable: ${name}`);
49
+ }
50
+ return service;
51
+ }
52
+ applySnapshot(snapshot, source) {
53
+ const registryRevision = this.normalizeRevision(snapshot.revision, source);
54
+ if (registryRevision < this.revision) {
55
+ logClusterEvent("warn", "registry_revision_regressed", this.context, {
56
+ source,
57
+ cacheRevision: this.revision,
58
+ registryRevision,
59
+ });
60
+ this.invalidate("revision_regressed");
61
+ }
62
+ this.assertCacheBound(registryRevision, `${source}:pre_apply`);
63
+ this.revision = registryRevision;
64
+ this.services.clear();
65
+ for (const service of snapshot.services) {
66
+ this.services.set(service.name, { ...service });
67
+ }
68
+ this.assertCacheBound(registryRevision, `${source}:post_apply`);
69
+ }
70
+ applyResolve(name, response) {
71
+ const registryRevision = this.normalizeRevision(response.revision, "resolve");
72
+ if (registryRevision < this.revision) {
73
+ logClusterEvent("warn", "registry_resolve_revision_regressed", this.context, {
74
+ cacheRevision: this.revision,
75
+ registryRevision,
76
+ serviceName: name,
77
+ });
78
+ this.invalidate("resolve_revision_regressed");
79
+ throw new Error("registry cache stale after leader change; refresh required");
80
+ }
81
+ this.assertCacheBound(registryRevision, "resolve:pre_apply");
82
+ this.revision = registryRevision;
83
+ if (response.service) {
84
+ this.services.set(name, { ...response.service });
85
+ }
86
+ else {
87
+ this.services.delete(name);
88
+ }
89
+ this.assertCacheBound(registryRevision, "resolve:post_apply");
90
+ }
91
+ assertCacheBound(registryRevision, source) {
92
+ if (this.revision > registryRevision) {
93
+ logClusterEvent("warn", "registry_cache_bound_violation", this.context, {
94
+ source,
95
+ cacheRevision: this.revision,
96
+ registryRevision,
97
+ });
98
+ throw new Error(`registry cache invariant violated: cacheVersion ${this.revision} > registryVersion ${registryRevision}`);
99
+ }
100
+ }
101
+ normalizeRevision(revision, source) {
102
+ if (!Number.isInteger(revision) || revision < 0) {
103
+ throw new Error(`invalid registry revision from ${source}: ${String(revision)}`);
104
+ }
105
+ return revision;
106
+ }
107
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { FollowerRegistryCache } from "./registry-cache.js";
3
+ import { createClusterLogContext } from "../observability.js";
4
+ function createClientStub(resolver) {
5
+ return {
6
+ call: vi.fn((method, params) => resolver(method, params)),
7
+ };
8
+ }
9
+ function service(name) {
10
+ return { name, version: "v2" };
11
+ }
12
+ describe("cluster_v2 follower registry cache", () => {
13
+ it("refreshes cache and resolves required services", async () => {
14
+ const client = createClientStub(async (method, params) => {
15
+ if (method === "registry.list") {
16
+ return { revision: 3, services: [service("embedding"), service("bus")] };
17
+ }
18
+ if (method === "registry.resolve") {
19
+ const name = params.name;
20
+ return { revision: 3, service: service(name) };
21
+ }
22
+ throw new Error(`unexpected method: ${method}`);
23
+ });
24
+ const cache = new FollowerRegistryCache(client, createClusterLogContext({ role: "test" }));
25
+ await cache.refresh();
26
+ expect(cache.currentRevision()).toBe(3);
27
+ await expect(cache.requireService("embedding")).resolves.toEqual(service("embedding"));
28
+ await expect(cache.requireService("bus")).resolves.toEqual(service("bus"));
29
+ });
30
+ it("invalidates and rejects stale resolve when revision regresses", async () => {
31
+ let phase = "list";
32
+ const client = createClientStub(async (method) => {
33
+ if (method === "registry.list" && phase === "list") {
34
+ phase = "resolve";
35
+ return { revision: 5, services: [service("embedding")] };
36
+ }
37
+ if (method === "registry.resolve" && phase === "resolve") {
38
+ return { revision: 4, service: service("bus") };
39
+ }
40
+ throw new Error(`unexpected call for phase ${phase}: ${method}`);
41
+ });
42
+ const cache = new FollowerRegistryCache(client, createClusterLogContext({ role: "test" }));
43
+ await cache.refresh();
44
+ expect(cache.currentRevision()).toBe(5);
45
+ await expect(cache.resolve("bus")).rejects.toThrow("stale");
46
+ expect(cache.currentRevision()).toBe(0);
47
+ });
48
+ it("drops cache contents on explicit invalidation", async () => {
49
+ const client = createClientStub(async (method, params) => {
50
+ if (method === "registry.list") {
51
+ return { revision: 2, services: [service("embedding")] };
52
+ }
53
+ if (method === "registry.resolve") {
54
+ const name = params.name;
55
+ return { revision: 2, service: name === "embedding" ? undefined : service(name) };
56
+ }
57
+ throw new Error(`unexpected method: ${method}`);
58
+ });
59
+ const cache = new FollowerRegistryCache(client, createClusterLogContext({ role: "test" }));
60
+ await cache.refresh();
61
+ expect(cache.currentRevision()).toBe(2);
62
+ cache.invalidate("leader_disconnect");
63
+ expect(cache.currentRevision()).toBe(0);
64
+ await expect(cache.requireService("embedding")).rejects.toThrow("required service unavailable");
65
+ });
66
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Leader-local service registry with monotonic revision.
3
+ * Followers can use revision to invalidate stale snapshots after failover.
4
+ */
5
+ export class ServiceRegistry {
6
+ services = new Map();
7
+ revision = 0;
8
+ register(service) {
9
+ this.services.set(service.name, { ...service });
10
+ this.revision++;
11
+ return service;
12
+ }
13
+ unregister(name) {
14
+ const deleted = this.services.delete(name);
15
+ if (deleted) {
16
+ this.revision++;
17
+ }
18
+ return deleted;
19
+ }
20
+ resolve(name) {
21
+ const svc = this.services.get(name);
22
+ return svc ? { ...svc } : undefined;
23
+ }
24
+ list() {
25
+ return [...this.services.values()].map((service) => ({ ...service }));
26
+ }
27
+ getRevision() {
28
+ return this.revision;
29
+ }
30
+ snapshot() {
31
+ return {
32
+ revision: this.revision,
33
+ services: this.list(),
34
+ };
35
+ }
36
+ }