volute 0.18.0 → 0.19.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 (104) hide show
  1. package/README.md +1 -1
  2. package/dist/archive-ZCFOSTKB.js +15 -0
  3. package/dist/{channel-SLURLIRV.js → channel-PUQKGSQM.js} +60 -7
  4. package/dist/{chunk-AYB7XAWO.js → chunk-2TJGRJ4O.js} +114 -279
  5. package/dist/{chunk-6BDNWYKG.js → chunk-32VR2EOH.js} +2 -2
  6. package/dist/chunk-4KPUF5JD.js +214 -0
  7. package/dist/{chunk-QJIIHU32.js → chunk-7NO7EV5Z.js} +2 -2
  8. package/dist/chunk-AW7P4EVV.js +159 -0
  9. package/dist/{chunk-2Y77MCFG.js → chunk-DYZGP3EW.js} +2 -2
  10. package/dist/{chunk-M77QBTEH.js → chunk-EBGCNDMM.js} +24 -14
  11. package/dist/{chunk-GSPWIM5E.js → chunk-EMQSAY3B.js} +77 -6
  12. package/dist/{chunk-37X7ECMF.js → chunk-FCDU5BFX.js} +1 -1
  13. package/dist/chunk-FGV2H4TX.js +803 -0
  14. package/dist/{chunk-ZCEYUUID.js → chunk-OGXOMR65.js} +2 -1
  15. package/dist/chunk-OTWLI7F4.js +375 -0
  16. package/dist/{chunk-GK4E7LM7.js → chunk-RHEGSQFJ.js} +1 -1
  17. package/dist/{chunk-MVSXRMJJ.js → chunk-SCUDS4US.js} +1 -1
  18. package/dist/{chunk-FW5API7X.js → chunk-UJ6GHNR7.js} +2 -2
  19. package/dist/{chunk-OYSZNX5I.js → chunk-VDWCHYTS.js} +1 -1
  20. package/dist/{chunk-6DVBMLVN.js → chunk-VE4D3GOP.js} +2 -2
  21. package/dist/chunk-VQWDC6UK.js +142 -0
  22. package/dist/{chunk-OJQ47SCA.js → chunk-WC6ZHVRL.js} +1 -1
  23. package/dist/chunk-YUIHSKR6.js +72 -0
  24. package/dist/chunk-Z524RFCJ.js +36 -0
  25. package/dist/cli.js +33 -25
  26. package/dist/{connector-3ELFMI2R.js → connector-JBVNZ7VK.js} +6 -6
  27. package/dist/connectors/discord.js +2 -2
  28. package/dist/connectors/slack.js +2 -2
  29. package/dist/connectors/telegram.js +2 -2
  30. package/dist/{create-ZWHCRT5F.js → create-HP4OVVHF.js} +6 -4
  31. package/dist/{daemon-client-ODKDUYDE.js → daemon-client-ITWUCNFO.js} +2 -2
  32. package/dist/{daemon-restart-2HVTHZAT.js → daemon-restart-JMZM3QY4.js} +8 -8
  33. package/dist/daemon.js +1144 -1108
  34. package/dist/db-5ZVC6MQF.js +10 -0
  35. package/dist/{delete-6G6WEX4F.js → delete-BSU7K3RY.js} +1 -1
  36. package/dist/delivery-manager-ISTJMZDW.js +16 -0
  37. package/dist/down-ZY35KMHR.js +14 -0
  38. package/dist/{env-6IDWGBUH.js → env-A3LMO777.js} +6 -6
  39. package/dist/export-GCDNQCF3.js +100 -0
  40. package/dist/{history-YUEKTJ2N.js → history-WNK3DFUM.js} +6 -6
  41. package/dist/{import-EDGRLIGO.js → import-M63VIUJ5.js} +3 -3
  42. package/dist/log-PPPZDVEF.js +39 -0
  43. package/dist/{login-ORQDXLBM.js → login-HNH3EUQV.js} +2 -2
  44. package/dist/{logout-XC5AUO5I.js → logout-I5CB5UZS.js} +2 -2
  45. package/dist/{logs-GYOR3L2L.js → logs-SF2IMJN4.js} +6 -6
  46. package/dist/merge-33C237A4.js +46 -0
  47. package/dist/{mind-OJN6RBZW.js → mind-PQ5NCPSU.js} +14 -10
  48. package/dist/mind-manager-RVCFROAY.js +18 -0
  49. package/dist/{package-OKLFO7UY.js → package-MYE2ZJLV.js} +5 -3
  50. package/dist/{pages-6IV4VQTU.js → pages-AXCOSY3P.js} +2 -2
  51. package/dist/{publish-Q4RPSJLL.js → publish-YB377JB7.js} +18 -4
  52. package/dist/pull-XAEWQJ47.js +39 -0
  53. package/dist/{register-LDE6LRXY.js → register-VSPCMHKX.js} +2 -2
  54. package/dist/{restart-YFAWFS5T.js → restart-IQKMCK5M.js} +6 -6
  55. package/dist/{schedule-AGYLDMNS.js → schedule-LMX7GAQZ.js} +6 -6
  56. package/dist/schema-5BW7DFZI.js +24 -0
  57. package/dist/{seed-AP4Q7RZ7.js → seed-J43YDKXG.js} +7 -4
  58. package/dist/{send-BNDTLUPM.js → send-KVIZIGCE.js} +8 -8
  59. package/dist/{service-U7MZ2H7F.js → service-LUR7WDO7.js} +6 -6
  60. package/dist/{setup-DJKIZKGW.js → setup-OH3PJUJO.js} +7 -7
  61. package/dist/shared-KO35ZM44.js +39 -0
  62. package/dist/{skill-2Y42P4JY.js → skill-BCVNI6TV.js} +6 -6
  63. package/{templates/_base/_skills → dist/skills}/orientation/SKILL.md +1 -1
  64. package/{templates/_base/_skills → dist/skills}/sessions/SKILL.md +2 -2
  65. package/{templates/_base/_skills → dist/skills}/volute-mind/SKILL.md +19 -1
  66. package/dist/{sprout-TJ3BHVOG.js → sprout-VBEX63LX.js} +38 -20
  67. package/dist/{start-3YYRXBKP.js → start-I5JYB65M.js} +6 -6
  68. package/dist/{status-VSFZYX7S.js → status-4ESFLGH4.js} +5 -5
  69. package/dist/status-D7E5HHBV.js +35 -0
  70. package/dist/{status-OKNA6AR3.js → status-JCJAOXTW.js} +2 -2
  71. package/dist/{stop-AA5K5LYG.js → stop-NBVKEFQQ.js} +6 -6
  72. package/dist/{up-7B3BWF2U.js → up-WG65SWJU.js} +5 -5
  73. package/dist/{update-YAGN5ODG.js → update-FJIHDJKM.js} +5 -5
  74. package/dist/{update-check-APLTH4IN.js → update-check-MWE5AH4U.js} +2 -2
  75. package/dist/{upgrade-KXZCQSZN.js → upgrade-AIT24B5I.js} +1 -1
  76. package/dist/{variant-X5QFG6KK.js → variant-63ZWO2W7.js} +4 -4
  77. package/dist/variants-JAGWGBXG.js +26 -0
  78. package/dist/web-assets/assets/index-BAbuRsVF.css +1 -0
  79. package/dist/web-assets/assets/index-CiQhSKi_.js +63 -0
  80. package/dist/web-assets/index.html +2 -2
  81. package/drizzle/0010_delivery_queue.sql +12 -0
  82. package/drizzle/0011_rename_human_to_brain.sql +1 -0
  83. package/drizzle/meta/0010_snapshot.json +7 -0
  84. package/drizzle/meta/0011_snapshot.json +7 -0
  85. package/drizzle/meta/_journal.json +14 -0
  86. package/package.json +5 -3
  87. package/templates/_base/.init/.config/hooks/startup-context.sh +1 -1
  88. package/templates/_base/.init/.config/scripts/session-reader.ts +3 -3
  89. package/templates/_base/home/VOLUTE.md +16 -1
  90. package/templates/_base/src/lib/auto-commit.ts +51 -14
  91. package/templates/_base/src/lib/router.ts +123 -1
  92. package/templates/_base/src/lib/types.ts +4 -0
  93. package/templates/_base/src/lib/volute-server.ts +91 -2
  94. package/templates/claude/src/server.ts +2 -2
  95. package/templates/claude/volute-template.json +1 -2
  96. package/templates/pi/src/agent.ts +1 -1
  97. package/templates/pi/src/lib/session-context-extension.ts +2 -2
  98. package/templates/pi/volute-template.json +1 -2
  99. package/dist/chunk-PO5Q2AYN.js +0 -121
  100. package/dist/down-A56B5JLK.js +0 -14
  101. package/dist/mind-manager-Z7O7PN2O.js +0 -15
  102. package/dist/web-assets/assets/index-CtiimdWK.css +0 -1
  103. package/dist/web-assets/assets/index-kt1_EcuO.js +0 -63
  104. /package/{templates/_base/_skills → dist/skills}/memory/SKILL.md +0 -0
@@ -0,0 +1,803 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ logger_default
4
+ } from "./chunk-YUIHSKR6.js";
5
+ import {
6
+ getDb
7
+ } from "./chunk-Z524RFCJ.js";
8
+ import {
9
+ deliveryQueue,
10
+ mindHistory
11
+ } from "./chunk-VQWDC6UK.js";
12
+ import {
13
+ findMind,
14
+ findVariant,
15
+ mindDir
16
+ } from "./chunk-EBGCNDMM.js";
17
+
18
+ // src/lib/delivery-manager.ts
19
+ import { and, eq, sql } from "drizzle-orm";
20
+
21
+ // src/lib/delivery-router.ts
22
+ import { readFileSync, statSync } from "fs";
23
+ import { resolve } from "path";
24
+ var configCache = /* @__PURE__ */ new Map();
25
+ var dlog = logger_default.child("delivery-router");
26
+ function configPath(mindName) {
27
+ return resolve(mindDir(mindName), "home/.config/routes.json");
28
+ }
29
+ function getRoutingConfig(mindName) {
30
+ const path = configPath(mindName);
31
+ let mtime;
32
+ try {
33
+ mtime = statSync(path).mtimeMs;
34
+ } catch {
35
+ configCache.delete(mindName);
36
+ return {};
37
+ }
38
+ const cached = configCache.get(mindName);
39
+ if (cached && cached.mtime === mtime) {
40
+ return cached.config;
41
+ }
42
+ try {
43
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
44
+ const config = Array.isArray(parsed) ? { rules: parsed } : parsed;
45
+ configCache.set(mindName, { config, mtime });
46
+ return config;
47
+ } catch (err) {
48
+ dlog.warn(`failed to load routes.json for ${mindName}`, logger_default.errorData(err));
49
+ configCache.delete(mindName);
50
+ return {};
51
+ }
52
+ }
53
+ function globMatch(pattern, value) {
54
+ const regex = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
55
+ return new RegExp(`^${regex}$`).test(value);
56
+ }
57
+ var GLOB_MATCH_KEYS = /* @__PURE__ */ new Set(["channel", "sender"]);
58
+ var NON_MATCH_KEYS = /* @__PURE__ */ new Set(["session", "destination", "path", "mode"]);
59
+ function ruleMatches(rule, meta) {
60
+ for (const [key, pattern] of Object.entries(rule)) {
61
+ if (NON_MATCH_KEYS.has(key)) continue;
62
+ if (key === "isDM") {
63
+ if (typeof pattern !== "boolean") return false;
64
+ if ((meta.isDM ?? false) !== pattern) return false;
65
+ continue;
66
+ }
67
+ if (key === "participants") {
68
+ if (typeof pattern !== "number") return false;
69
+ if ((meta.participantCount ?? 0) !== pattern) return false;
70
+ continue;
71
+ }
72
+ if (typeof pattern !== "string") return false;
73
+ if (!GLOB_MATCH_KEYS.has(key)) return false;
74
+ const value = meta[key] ?? "";
75
+ if (!globMatch(pattern, value)) return false;
76
+ }
77
+ return true;
78
+ }
79
+ function expandTemplate(template, meta) {
80
+ return template.replace(/\$\{sender\}/g, meta.sender ?? "unknown").replace(/\$\{channel\}/g, meta.channel ?? "unknown");
81
+ }
82
+ function sanitizeSessionName(name) {
83
+ return name.replace(/\0/g, "").replace(/[/\\]/g, "-").replace(/\.\./g, "-").slice(0, 100);
84
+ }
85
+ function resolveRoute(config, meta) {
86
+ const fallback = config.default ?? "main";
87
+ if (!config.rules) {
88
+ return { destination: "mind", session: fallback, matched: false };
89
+ }
90
+ for (const rule of config.rules) {
91
+ if (ruleMatches(rule, meta)) {
92
+ if (rule.destination === "file") {
93
+ if (!rule.path) {
94
+ dlog.warn("file destination rule missing path \u2014 falling through");
95
+ continue;
96
+ }
97
+ return { destination: "file", path: rule.path, matched: true };
98
+ }
99
+ return {
100
+ destination: "mind",
101
+ session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
102
+ matched: true,
103
+ mode: rule.mode
104
+ };
105
+ }
106
+ }
107
+ return { destination: "mind", session: fallback, matched: false };
108
+ }
109
+ var DEFAULT_BATCH_DEBOUNCE = 5;
110
+ var DEFAULT_BATCH_MAX_WAIT = 120;
111
+ function normalizeBatchConfig(batch) {
112
+ if (typeof batch === "number") return { maxWait: batch * 60 };
113
+ return batch;
114
+ }
115
+ function resolveDeliveryMode(config, sessionName) {
116
+ const defaults = {
117
+ delivery: { mode: "immediate" },
118
+ interrupt: true
119
+ };
120
+ if (!config.sessions) return defaults;
121
+ for (const [pattern, sessionConfig] of Object.entries(config.sessions)) {
122
+ if (globMatch(pattern, sessionName)) {
123
+ let delivery;
124
+ if (sessionConfig.delivery != null) {
125
+ if (sessionConfig.delivery === "immediate") {
126
+ delivery = { mode: "immediate" };
127
+ } else if (sessionConfig.delivery === "batch") {
128
+ delivery = {
129
+ mode: "batch",
130
+ debounce: DEFAULT_BATCH_DEBOUNCE,
131
+ maxWait: DEFAULT_BATCH_MAX_WAIT
132
+ };
133
+ } else {
134
+ delivery = {
135
+ mode: "batch",
136
+ debounce: sessionConfig.delivery.debounce ?? DEFAULT_BATCH_DEBOUNCE,
137
+ maxWait: sessionConfig.delivery.maxWait ?? DEFAULT_BATCH_MAX_WAIT
138
+ };
139
+ }
140
+ } else if (sessionConfig.batch != null) {
141
+ const batch = normalizeBatchConfig(sessionConfig.batch);
142
+ delivery = {
143
+ mode: "batch",
144
+ debounce: batch.debounce ?? DEFAULT_BATCH_DEBOUNCE,
145
+ maxWait: batch.maxWait ?? DEFAULT_BATCH_MAX_WAIT,
146
+ triggers: batch.triggers
147
+ };
148
+ } else if (sessionConfig.interrupt === false) {
149
+ delivery = {
150
+ mode: "batch",
151
+ debounce: DEFAULT_BATCH_DEBOUNCE,
152
+ maxWait: DEFAULT_BATCH_MAX_WAIT
153
+ };
154
+ } else {
155
+ delivery = { mode: "immediate" };
156
+ }
157
+ return {
158
+ delivery,
159
+ interrupt: sessionConfig.interrupt ?? true,
160
+ instructions: sessionConfig.instructions
161
+ };
162
+ }
163
+ }
164
+ return defaults;
165
+ }
166
+
167
+ // src/lib/message-delivery.ts
168
+ var dlog2 = logger_default.child("delivery");
169
+ function extractTextContent(content) {
170
+ if (typeof content === "string") return content;
171
+ if (Array.isArray(content)) {
172
+ return content.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
173
+ }
174
+ return JSON.stringify(content);
175
+ }
176
+ async function deliverMessage(mindName, payload) {
177
+ try {
178
+ const [baseName] = mindName.split("@", 2);
179
+ const entry = findMind(baseName);
180
+ if (!entry) {
181
+ dlog2.warn(`cannot deliver to ${mindName}: mind not found`);
182
+ return;
183
+ }
184
+ const textContent = extractTextContent(payload.content);
185
+ try {
186
+ const db = await getDb();
187
+ await db.insert(mindHistory).values({
188
+ mind: baseName,
189
+ type: "inbound",
190
+ channel: payload.channel,
191
+ sender: payload.sender ?? null,
192
+ content: textContent
193
+ });
194
+ } catch (err) {
195
+ dlog2.warn(`failed to persist message for ${baseName}`, logger_default.errorData(err));
196
+ }
197
+ try {
198
+ const { getDeliveryManager: getDeliveryManager2 } = await import("./delivery-manager-ISTJMZDW.js");
199
+ const manager = getDeliveryManager2();
200
+ await manager.routeAndDeliver(mindName, payload);
201
+ return;
202
+ } catch (err) {
203
+ if (err instanceof Error && !err.message.includes("not initialized")) {
204
+ dlog2.warn("delivery manager error, falling back to direct delivery", logger_default.errorData(err));
205
+ }
206
+ }
207
+ const { findVariant: findVariant2 } = await import("./variants-JAGWGBXG.js");
208
+ const [, variantName] = mindName.split("@", 2);
209
+ let port = entry.port;
210
+ if (variantName) {
211
+ const variant = findVariant2(baseName, variantName);
212
+ if (!variant) {
213
+ dlog2.warn(`cannot deliver to ${mindName}: variant not found`);
214
+ return;
215
+ }
216
+ port = variant.port;
217
+ }
218
+ const body = JSON.stringify(payload);
219
+ const controller = new AbortController();
220
+ const timeout = setTimeout(() => controller.abort(), 12e4);
221
+ try {
222
+ const res = await fetch(`http://127.0.0.1:${port}/message`, {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/json" },
225
+ body,
226
+ signal: controller.signal
227
+ });
228
+ if (!res.ok) {
229
+ const text = await res.text().catch(() => "");
230
+ dlog2.warn(`mind ${mindName} responded ${res.status}: ${text}`);
231
+ } else {
232
+ await res.text().catch(() => {
233
+ });
234
+ }
235
+ } catch (err) {
236
+ dlog2.warn(`failed to deliver to ${mindName}`, logger_default.errorData(err));
237
+ } finally {
238
+ clearTimeout(timeout);
239
+ }
240
+ } catch (err) {
241
+ dlog2.warn(`unexpected error delivering to ${mindName}`, logger_default.errorData(err));
242
+ }
243
+ }
244
+
245
+ // src/lib/typing.ts
246
+ var DEFAULT_TTL_MS = 1e4;
247
+ var SWEEP_INTERVAL_MS = 5e3;
248
+ var TypingMap = class {
249
+ channels = /* @__PURE__ */ new Map();
250
+ sweepTimer;
251
+ constructor() {
252
+ this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
253
+ this.sweepTimer.unref();
254
+ }
255
+ set(channel, sender, opts) {
256
+ const expiresAt = opts?.persistent ? Infinity : Date.now() + (opts?.ttlMs ?? DEFAULT_TTL_MS);
257
+ let senders = this.channels.get(channel);
258
+ if (!senders) {
259
+ senders = /* @__PURE__ */ new Map();
260
+ this.channels.set(channel, senders);
261
+ }
262
+ senders.set(sender, { expiresAt });
263
+ }
264
+ delete(channel, sender) {
265
+ const senders = this.channels.get(channel);
266
+ if (senders) {
267
+ senders.delete(sender);
268
+ if (senders.size === 0) {
269
+ this.channels.delete(channel);
270
+ }
271
+ }
272
+ }
273
+ /** Remove a sender from all channels (e.g. when a mind finishes processing). */
274
+ deleteSender(sender) {
275
+ for (const [channel, senders] of this.channels) {
276
+ senders.delete(sender);
277
+ if (senders.size === 0) {
278
+ this.channels.delete(channel);
279
+ }
280
+ }
281
+ }
282
+ get(channel) {
283
+ const senders = this.channels.get(channel);
284
+ if (!senders) return [];
285
+ const now = Date.now();
286
+ const result = [];
287
+ for (const [sender, entry] of senders) {
288
+ if (entry.expiresAt > now) {
289
+ result.push(sender);
290
+ }
291
+ }
292
+ return result;
293
+ }
294
+ dispose() {
295
+ clearInterval(this.sweepTimer);
296
+ this.channels.clear();
297
+ if (instance === this) instance = void 0;
298
+ }
299
+ sweep() {
300
+ const now = Date.now();
301
+ for (const [channel, senders] of this.channels) {
302
+ for (const [sender, entry] of senders) {
303
+ if (entry.expiresAt <= now) {
304
+ senders.delete(sender);
305
+ }
306
+ }
307
+ if (senders.size === 0) {
308
+ this.channels.delete(channel);
309
+ }
310
+ }
311
+ }
312
+ };
313
+ var instance;
314
+ function getTypingMap() {
315
+ if (!instance) {
316
+ instance = new TypingMap();
317
+ }
318
+ return instance;
319
+ }
320
+
321
+ // src/lib/delivery-manager.ts
322
+ var dlog3 = logger_default.child("delivery-manager");
323
+ var MAX_BATCH_SIZE = 50;
324
+ var DeliveryManager = class {
325
+ sessionStates = /* @__PURE__ */ new Map();
326
+ batchBuffers = /* @__PURE__ */ new Map();
327
+ // --- Public API ---
328
+ /**
329
+ * Route and deliver a message to a mind. This is the main entry point.
330
+ * The message is routed via the mind's routes.json, then either delivered immediately
331
+ * or queued for batching depending on the session's delivery mode.
332
+ */
333
+ async routeAndDeliver(mindName, payload) {
334
+ const [baseName] = mindName.split("@", 2);
335
+ const config = getRoutingConfig(baseName);
336
+ const meta = {
337
+ channel: payload.channel,
338
+ sender: payload.sender ?? void 0,
339
+ isDM: payload.isDM,
340
+ participantCount: payload.participantCount
341
+ };
342
+ const route = resolveRoute(config, meta);
343
+ if (route.destination === "file") {
344
+ return { routed: true, session: route.path, destination: "file", mode: "immediate" };
345
+ }
346
+ if (!route.matched && config.gateUnmatched !== false) {
347
+ await this.gateMessage(mindName, route.session, payload);
348
+ return { routed: true, session: route.session, destination: "mind", mode: "gated" };
349
+ }
350
+ if (route.mode === "mention" && payload.sender) {
351
+ const text = extractTextContent(payload.content);
352
+ const escaped = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
353
+ const pattern = new RegExp(`\\b${escaped}\\b`, "i");
354
+ if (!pattern.test(text)) {
355
+ return { routed: false, reason: "mention-filtered" };
356
+ }
357
+ }
358
+ let sessionName = route.session;
359
+ if (sessionName === "$new") {
360
+ sessionName = `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
361
+ }
362
+ const sessionConfig = resolveDeliveryMode(config, sessionName);
363
+ if (sessionConfig.delivery.mode === "batch") {
364
+ this.enqueueBatch(mindName, sessionName, payload, sessionConfig);
365
+ return { routed: true, session: sessionName, destination: "mind", mode: "batch" };
366
+ }
367
+ await this.deliverToMind(mindName, sessionName, payload, sessionConfig);
368
+ return { routed: true, session: sessionName, destination: "mind", mode: "immediate" };
369
+ }
370
+ /**
371
+ * Called when a mind's session emits a "done" event — decrements active count
372
+ * and may trigger batch flush if session goes idle.
373
+ */
374
+ sessionDone(mindName, session) {
375
+ const [baseName] = mindName.split("@", 2);
376
+ if (session) {
377
+ this.decrementActive(baseName, session);
378
+ } else {
379
+ const mindSessions = this.sessionStates.get(baseName);
380
+ if (mindSessions) {
381
+ for (const [sessionName] of mindSessions) {
382
+ this.decrementActive(baseName, sessionName);
383
+ }
384
+ }
385
+ }
386
+ }
387
+ /**
388
+ * Restore queued messages from DB on daemon restart.
389
+ */
390
+ async restoreFromDb() {
391
+ try {
392
+ const db = await getDb();
393
+ const rows = await db.select().from(deliveryQueue).where(eq(deliveryQueue.status, "pending"));
394
+ for (const row of rows) {
395
+ let payload;
396
+ try {
397
+ payload = JSON.parse(row.payload);
398
+ } catch (parseErr) {
399
+ dlog3.warn(
400
+ `corrupt payload in delivery queue row ${row.id}, skipping`,
401
+ logger_default.errorData(parseErr)
402
+ );
403
+ continue;
404
+ }
405
+ const config = getRoutingConfig(row.mind);
406
+ const sessionConfig = resolveDeliveryMode(config, row.session);
407
+ if (sessionConfig.delivery.mode === "batch") {
408
+ this.addToBatchBuffer(row.mind, row.session, payload, sessionConfig);
409
+ } else {
410
+ this.deliverToMind(row.mind, row.session, payload, sessionConfig).then(async () => {
411
+ try {
412
+ const db2 = await getDb();
413
+ await db2.delete(deliveryQueue).where(eq(deliveryQueue.id, row.id));
414
+ } catch {
415
+ }
416
+ }).catch((err) => {
417
+ dlog3.warn(`failed to restore delivery for ${row.mind}`, logger_default.errorData(err));
418
+ });
419
+ }
420
+ }
421
+ if (rows.length > 0) {
422
+ dlog3.info(`restored ${rows.length} queued messages from DB`);
423
+ }
424
+ } catch (err) {
425
+ dlog3.warn("failed to restore delivery queue from DB", logger_default.errorData(err));
426
+ }
427
+ }
428
+ /**
429
+ * Get pending (gated) messages for a mind.
430
+ */
431
+ async getPending(mindName) {
432
+ const db = await getDb();
433
+ const rows = await db.select().from(deliveryQueue).where(and(eq(deliveryQueue.mind, mindName), eq(deliveryQueue.status, "gated")));
434
+ const byChannel = /* @__PURE__ */ new Map();
435
+ for (const row of rows) {
436
+ const ch = row.channel ?? "unknown";
437
+ const existing = byChannel.get(ch) ?? [];
438
+ existing.push(row);
439
+ byChannel.set(ch, existing);
440
+ }
441
+ return [...byChannel.entries()].map(([channel, channelRows]) => {
442
+ const firstRow = channelRows[0];
443
+ const payload = JSON.parse(firstRow.payload);
444
+ const text = extractTextContent(payload.content);
445
+ return {
446
+ channel,
447
+ sender: firstRow.sender,
448
+ count: channelRows.length,
449
+ firstSeen: firstRow.created_at,
450
+ preview: text.length > 200 ? `${text.slice(0, 200)}...` : text
451
+ };
452
+ });
453
+ }
454
+ /**
455
+ * Check if a session is currently busy (has active deliveries).
456
+ */
457
+ isSessionBusy(mindName, session) {
458
+ const state = this.sessionStates.get(mindName)?.get(session);
459
+ return (state?.activeCount ?? 0) > 0;
460
+ }
461
+ /**
462
+ * Cleanup all timers and subscriptions.
463
+ */
464
+ dispose() {
465
+ for (const [, buffer] of this.batchBuffers) {
466
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
467
+ if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
468
+ }
469
+ this.batchBuffers.clear();
470
+ this.sessionStates.clear();
471
+ if (instance2 === this) instance2 = void 0;
472
+ }
473
+ // --- Private ---
474
+ async deliverToMind(mindName, session, payload, sessionConfig) {
475
+ const [baseName, variantName] = mindName.split("@", 2);
476
+ const entry = findMind(baseName);
477
+ if (!entry) {
478
+ dlog3.warn(`cannot deliver to ${mindName}: mind not found`);
479
+ return;
480
+ }
481
+ let port = entry.port;
482
+ if (variantName) {
483
+ const variant = findVariant(baseName, variantName);
484
+ if (!variant) {
485
+ dlog3.warn(`cannot deliver to ${mindName}: variant not found`);
486
+ return;
487
+ }
488
+ port = variant.port;
489
+ }
490
+ this.incrementActive(baseName, session);
491
+ const typingMap = getTypingMap();
492
+ if (payload.channel) {
493
+ typingMap.set(payload.channel, baseName, { persistent: true });
494
+ }
495
+ const deliveryBody = {
496
+ ...payload,
497
+ session,
498
+ interrupt: sessionConfig.interrupt,
499
+ instructions: sessionConfig.instructions
500
+ };
501
+ const body = JSON.stringify(deliveryBody);
502
+ const controller = new AbortController();
503
+ const timeout = setTimeout(() => controller.abort(), 12e4);
504
+ try {
505
+ const res = await fetch(`http://127.0.0.1:${port}/message`, {
506
+ method: "POST",
507
+ headers: { "Content-Type": "application/json" },
508
+ body,
509
+ signal: controller.signal
510
+ });
511
+ if (!res.ok) {
512
+ const text = await res.text().catch(() => "");
513
+ dlog3.warn(`mind ${mindName} responded ${res.status}: ${text}`);
514
+ this.decrementActive(baseName, session);
515
+ if (payload.channel) typingMap.delete(payload.channel, baseName);
516
+ } else {
517
+ await res.text().catch(() => {
518
+ });
519
+ }
520
+ } catch (err) {
521
+ dlog3.warn(`failed to deliver to ${mindName}`, logger_default.errorData(err));
522
+ this.decrementActive(baseName, session);
523
+ if (payload.channel) typingMap.delete(payload.channel, baseName);
524
+ } finally {
525
+ clearTimeout(timeout);
526
+ }
527
+ }
528
+ async deliverBatchToMind(mindName, session, messages, sessionConfig) {
529
+ const [baseName, variantName] = mindName.split("@", 2);
530
+ const entry = findMind(baseName);
531
+ if (!entry) {
532
+ dlog3.warn(`cannot deliver batch to ${mindName}: mind not found`);
533
+ return;
534
+ }
535
+ let port = entry.port;
536
+ if (variantName) {
537
+ const variant = findVariant(baseName, variantName);
538
+ if (!variant) {
539
+ dlog3.warn(`cannot deliver batch to ${mindName}: variant not found`);
540
+ return;
541
+ }
542
+ port = variant.port;
543
+ }
544
+ const channels = {};
545
+ for (const msg of messages) {
546
+ const ch = msg.channel ?? "unknown";
547
+ if (!channels[ch]) channels[ch] = [];
548
+ channels[ch].push(msg.payload);
549
+ }
550
+ this.incrementActive(baseName, session);
551
+ const typingMap = getTypingMap();
552
+ for (const ch of Object.keys(channels)) {
553
+ if (ch !== "unknown") typingMap.set(ch, baseName, { persistent: true });
554
+ }
555
+ const batchBody = {
556
+ session,
557
+ batch: { channels },
558
+ interrupt: sessionConfig.interrupt,
559
+ instructions: sessionConfig.instructions
560
+ };
561
+ const body = JSON.stringify(batchBody);
562
+ const controller = new AbortController();
563
+ const timeout = setTimeout(() => controller.abort(), 12e4);
564
+ try {
565
+ const res = await fetch(`http://127.0.0.1:${port}/message`, {
566
+ method: "POST",
567
+ headers: { "Content-Type": "application/json" },
568
+ body,
569
+ signal: controller.signal
570
+ });
571
+ if (!res.ok) {
572
+ const text = await res.text().catch(() => "");
573
+ dlog3.warn(`mind ${mindName} batch responded ${res.status}: ${text}`);
574
+ this.decrementActive(baseName, session);
575
+ for (const ch of Object.keys(channels)) {
576
+ typingMap.delete(ch, baseName);
577
+ }
578
+ } else {
579
+ await res.text().catch(() => {
580
+ });
581
+ try {
582
+ const db = await getDb();
583
+ await db.delete(deliveryQueue).where(
584
+ and(
585
+ eq(deliveryQueue.mind, baseName),
586
+ eq(deliveryQueue.session, session),
587
+ eq(deliveryQueue.status, "pending")
588
+ )
589
+ );
590
+ } catch (err) {
591
+ dlog3.warn(
592
+ `failed to clean delivery queue for ${baseName}/${session}`,
593
+ logger_default.errorData(err)
594
+ );
595
+ }
596
+ }
597
+ } catch (err) {
598
+ dlog3.warn(`failed to deliver batch to ${mindName}`, logger_default.errorData(err));
599
+ this.decrementActive(baseName, session);
600
+ for (const ch of Object.keys(channels)) {
601
+ typingMap.delete(ch, baseName);
602
+ }
603
+ } finally {
604
+ clearTimeout(timeout);
605
+ }
606
+ }
607
+ enqueueBatch(mindName, session, payload, sessionConfig) {
608
+ const delivery = sessionConfig.delivery;
609
+ if (delivery.triggers?.length) {
610
+ const text = extractTextContent(payload.content);
611
+ const lower = text.toLowerCase();
612
+ if (delivery.triggers.some((t) => lower.includes(t.toLowerCase()))) {
613
+ this.flushBatch(mindName, session, [
614
+ {
615
+ payload,
616
+ channel: payload.channel,
617
+ sender: payload.sender ?? null,
618
+ createdAt: Date.now()
619
+ }
620
+ ]);
621
+ return;
622
+ }
623
+ }
624
+ this.persistToQueue(mindName, session, payload).catch((err) => {
625
+ dlog3.warn(`failed to persist batch message for ${mindName}/${session}`, logger_default.errorData(err));
626
+ });
627
+ this.addToBatchBuffer(mindName, session, payload, sessionConfig);
628
+ }
629
+ addToBatchBuffer(mindName, session, payload, sessionConfig) {
630
+ const delivery = sessionConfig.delivery;
631
+ const bufferKey = `${mindName}:${session}`;
632
+ let buffer = this.batchBuffers.get(bufferKey);
633
+ if (!buffer) {
634
+ buffer = {
635
+ messages: [],
636
+ debounceTimer: null,
637
+ maxWaitTimer: null,
638
+ delivery
639
+ };
640
+ this.batchBuffers.set(bufferKey, buffer);
641
+ }
642
+ buffer.messages.push({
643
+ payload,
644
+ channel: payload.channel,
645
+ sender: payload.sender ?? null,
646
+ createdAt: Date.now()
647
+ });
648
+ if (buffer.messages.length >= MAX_BATCH_SIZE) {
649
+ this.flushBatch(mindName, session);
650
+ return;
651
+ }
652
+ this.scheduleBatchTimers(mindName, session, bufferKey);
653
+ }
654
+ scheduleBatchTimers(mindName, session, bufferKey) {
655
+ const buffer = this.batchBuffers.get(bufferKey);
656
+ if (!buffer) return;
657
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
658
+ buffer.debounceTimer = setTimeout(() => {
659
+ if (!this.isSessionBusy(mindName, session)) {
660
+ this.flushBatch(mindName, session);
661
+ }
662
+ }, buffer.delivery.debounce * 1e3);
663
+ buffer.debounceTimer.unref();
664
+ if (!buffer.maxWaitTimer) {
665
+ buffer.maxWaitTimer = setTimeout(() => {
666
+ this.flushBatch(mindName, session);
667
+ }, buffer.delivery.maxWait * 1e3);
668
+ buffer.maxWaitTimer.unref();
669
+ }
670
+ }
671
+ flushBatch(mindName, session, extra) {
672
+ const bufferKey = `${mindName}:${session}`;
673
+ const buffer = this.batchBuffers.get(bufferKey);
674
+ const messages = [];
675
+ if (buffer) {
676
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
677
+ if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
678
+ buffer.debounceTimer = null;
679
+ buffer.maxWaitTimer = null;
680
+ messages.push(...buffer.messages.splice(0));
681
+ this.batchBuffers.delete(bufferKey);
682
+ }
683
+ if (extra) messages.push(...extra);
684
+ if (messages.length === 0) return;
685
+ const [baseName] = mindName.split("@", 2);
686
+ const config = getRoutingConfig(baseName);
687
+ const sessionConfig = resolveDeliveryMode(config, session);
688
+ dlog3.info(`flushing batch for ${mindName}/${session}: ${messages.length} messages`);
689
+ this.deliverBatchToMind(mindName, session, messages, sessionConfig).catch((err) => {
690
+ dlog3.warn(`failed to flush batch for ${mindName}/${session}`, logger_default.errorData(err));
691
+ });
692
+ }
693
+ async gateMessage(mindName, session, payload) {
694
+ const [baseName] = mindName.split("@", 2);
695
+ await this.persistToQueue(baseName, session, payload, "gated");
696
+ try {
697
+ const db = await getDb();
698
+ const count = await db.select({ count: sql`count(*)` }).from(deliveryQueue).where(
699
+ and(
700
+ eq(deliveryQueue.mind, baseName),
701
+ eq(deliveryQueue.channel, payload.channel),
702
+ eq(deliveryQueue.status, "gated")
703
+ )
704
+ );
705
+ if ((count[0]?.count ?? 0) <= 1) {
706
+ await this.sendInviteNotification(mindName, payload);
707
+ }
708
+ } catch (err) {
709
+ dlog3.warn(`failed to check gated count for ${baseName}`, logger_default.errorData(err));
710
+ }
711
+ }
712
+ async sendInviteNotification(mindName, payload) {
713
+ const text = extractTextContent(payload.content);
714
+ const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
715
+ const channel = payload.channel ?? "unknown";
716
+ const notification = [
717
+ `[New channel: ${channel}]`,
718
+ `Sender: ${payload.sender ?? "unknown"}`,
719
+ payload.platform ? `Platform: ${payload.platform}` : null,
720
+ payload.participantCount ? `Participants: ${payload.participantCount}` : null,
721
+ "",
722
+ `Preview: ${preview}`,
723
+ "",
724
+ `To accept this channel, add a routing rule for "${channel}" to your routes.json.`,
725
+ `Messages are being held until a route is configured.`
726
+ ].filter((line) => line !== null).join("\n");
727
+ const invitePayload = {
728
+ channel: "system:delivery",
729
+ sender: "system",
730
+ content: [{ type: "text", text: notification }]
731
+ };
732
+ const config = getRoutingConfig(mindName.split("@", 2)[0]);
733
+ const sessionConfig = resolveDeliveryMode(config, "main");
734
+ await this.deliverToMind(mindName, "main", invitePayload, {
735
+ ...sessionConfig,
736
+ interrupt: true
737
+ });
738
+ }
739
+ async persistToQueue(mindName, session, payload, status = "pending") {
740
+ try {
741
+ const db = await getDb();
742
+ await db.insert(deliveryQueue).values({
743
+ mind: mindName,
744
+ session,
745
+ channel: payload.channel ?? null,
746
+ sender: payload.sender ?? null,
747
+ status,
748
+ payload: JSON.stringify(payload)
749
+ });
750
+ } catch (err) {
751
+ dlog3.warn(
752
+ `failed to persist to delivery queue for ${mindName}/${session}`,
753
+ logger_default.errorData(err)
754
+ );
755
+ }
756
+ }
757
+ incrementActive(mind, session) {
758
+ let mindSessions = this.sessionStates.get(mind);
759
+ if (!mindSessions) {
760
+ mindSessions = /* @__PURE__ */ new Map();
761
+ this.sessionStates.set(mind, mindSessions);
762
+ }
763
+ const state = mindSessions.get(session) ?? { activeCount: 0, lastDeliveredAt: 0 };
764
+ state.activeCount++;
765
+ state.lastDeliveredAt = Date.now();
766
+ mindSessions.set(session, state);
767
+ }
768
+ decrementActive(mind, session) {
769
+ const mindSessions = this.sessionStates.get(mind);
770
+ if (!mindSessions) return;
771
+ const state = mindSessions.get(session);
772
+ if (!state) return;
773
+ state.activeCount = Math.max(0, state.activeCount - 1);
774
+ if (state.activeCount === 0) {
775
+ const bufferKey = `${mind}:${session}`;
776
+ const buffer = this.batchBuffers.get(bufferKey);
777
+ if (buffer && buffer.messages.length > 0) {
778
+ this.scheduleBatchTimers(mind, session, bufferKey);
779
+ }
780
+ }
781
+ }
782
+ };
783
+ var instance2;
784
+ function initDeliveryManager() {
785
+ if (instance2) return instance2;
786
+ instance2 = new DeliveryManager();
787
+ return instance2;
788
+ }
789
+ function getDeliveryManager() {
790
+ if (!instance2) {
791
+ throw new Error("DeliveryManager not initialized \u2014 call initDeliveryManager() first");
792
+ }
793
+ return instance2;
794
+ }
795
+
796
+ export {
797
+ extractTextContent,
798
+ deliverMessage,
799
+ getTypingMap,
800
+ DeliveryManager,
801
+ initDeliveryManager,
802
+ getDeliveryManager
803
+ };