volute 0.20.0 → 0.22.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/README.md +7 -7
- package/dist/{activity-events-OMXKXD5N.js → activity-events-3WHHCOBB.js} +3 -4
- package/dist/api.d.ts +4294 -0
- package/dist/{archive-ZCFOSTKB.js → archive-4ZQYK5MN.js} +4 -2
- package/dist/auth-HM2RSPY7.js +37 -0
- package/dist/{channel-PUQKGSQM.js → channel-BOOMFULW.js} +2 -2
- package/dist/{chunk-UU7A7KLB.js → chunk-A4S7H6G6.js} +5 -7
- package/dist/chunk-AKPFNL7L.js +148 -0
- package/dist/{chunk-EBGCNDMM.js → chunk-B2CPS4QU.js} +128 -114
- package/dist/chunk-G5KRTU2F.js +76 -0
- package/dist/{chunk-FCDU5BFX.js → chunk-HFCBO2GL.js} +2 -2
- package/dist/{chunk-GZ7DW4YL.js → chunk-HGCDWKSP.js} +2 -2
- package/dist/{chunk-7UFKREVW.js → chunk-JNFRY2WU.js} +2 -2
- package/dist/{chunk-DYZGP3EW.js → chunk-JTDFJWI2.js} +2 -1
- package/dist/{chunk-WC6ZHVRL.js → chunk-KFI7TQJ6.js} +2 -2
- package/dist/{chunk-AW7P4EVV.js → chunk-KTJGZ7M7.js} +55 -7
- package/dist/{chunk-OGXOMR65.js → chunk-NWPT4ASZ.js} +1 -1
- package/dist/{chunk-SCUDS4US.js → chunk-ON3FF5JA.js} +1 -1
- package/dist/chunk-OSFGKF2T.js +2651 -0
- package/dist/{chunk-TIWH32HP.js → chunk-PHHKNGA3.js} +3 -3
- package/dist/{chunk-VDWCHYTS.js → chunk-PHU4DEAJ.js} +1 -1
- package/dist/{chunk-7NO7EV5Z.js → chunk-QIXPN3OO.js} +2 -2
- package/dist/{chunk-O6ASDHFO.js → chunk-RK627D57.js} +40 -63
- package/dist/{chunk-NSE7VJQA.js → chunk-SGPEZ32F.js} +29 -1
- package/dist/{chunk-IKMY5X76.js → chunk-TFS25FIM.js} +12 -9
- package/dist/{chunk-PUVXOZ6T.js → chunk-VNVCRVYI.js} +118 -69
- package/dist/{chunk-32VR2EOH.js → chunk-VT5QODNE.js} +2 -2
- package/dist/{chunk-RHEGSQFJ.js → chunk-WSLPZF72.js} +1 -1
- package/dist/chunk-XLC342FO.js +29 -0
- package/dist/cli.js +57 -119
- package/dist/cloud-sync-C6WRYRVR.js +96 -0
- package/dist/{connector-JBVNZ7VK.js → connector-PYT5UOTZ.js} +6 -6
- package/dist/connectors/discord.js +2 -2
- package/dist/connectors/slack.js +2 -2
- package/dist/connectors/telegram.js +2 -2
- package/dist/{create-HP4OVVHF.js → create-WIDA3M4C.js} +1 -1
- package/dist/{daemon-client-ITWUCNFO.js → daemon-client-ZHCDL4RS.js} +2 -2
- package/dist/{daemon-restart-KPSWNYTH.js → daemon-restart-TPQ2XBRZ.js} +6 -6
- package/dist/daemon.js +2250 -1985
- package/dist/{delete-BSU7K3RY.js → delete-LOIANQGD.js} +1 -1
- package/dist/down-WSUASL5E.js +14 -0
- package/dist/{env-A3LMO777.js → env-4PHIHTF4.js} +2 -2
- package/dist/{export-6QBUOQGC.js → export-XD6PJBQP.js} +19 -8
- package/dist/{file-C57SK5DK.js → file-X4L5TTOL.js} +2 -2
- package/dist/{history-WNK3DFUM.js → history-HTEKRNID.js} +2 -2
- package/dist/{import-XEC34Y4Z.js → import-EAXTHHXL.js} +4 -3
- package/dist/{log-PPPZDVEF.js → log-SRO5Q6AD.js} +2 -2
- package/dist/{login-HNH3EUQV.js → login-UO6AOVEA.js} +4 -4
- package/dist/{logout-I5CB5UZS.js → logout-UKD5LA37.js} +2 -2
- package/dist/{logs-SF2IMJN4.js → logs-HNTNNBDW.js} +2 -2
- package/dist/{merge-33C237A4.js → merge-B6SYTGI7.js} +2 -2
- package/dist/message-delivery-WUS4K4ZC.js +21 -0
- package/dist/{mind-Z7CKD6DG.js → mind-BTXR5B3C.js} +35 -11
- package/dist/{mind-activity-tracker-624QLQLC.js → mind-activity-tracker-PGC3DBJ7.js} +4 -5
- package/dist/{mind-manager-3DMYKZPB.js → mind-manager-P5OBDUKI.js} +5 -6
- package/dist/mind-sleep-FWRBIFBS.js +41 -0
- package/dist/mind-wake-LJK2YU5X.js +36 -0
- package/dist/{package-4NHAVUUI.js → package-A7PEYJI2.js} +10 -1
- package/dist/{pages-4DGQT7ZA.js → pages-YSTRWJR4.js} +6 -6
- package/dist/{publish-TAJUET4I.js → publish-BZNHKUUK.js} +6 -6
- package/dist/{pull-XAEWQJ47.js → pull-GRQAXM2E.js} +2 -2
- package/dist/{register-VSPCMHKX.js → register-U2UO6TC4.js} +5 -5
- package/dist/registry-D2BSQ2X5.js +42 -0
- package/dist/{restart-IQKMCK5M.js → restart-CIDAKGG2.js} +3 -6
- package/dist/{schedule-FFZG23IW.js → schedule-NLR3LZLY.js} +2 -2
- package/dist/{seed-J43YDKXG.js → seed-3H2MRREW.js} +2 -2
- package/dist/{send-KVIZIGCE.js → send-RP2TA7SG.js} +132 -36
- package/dist/{service-LUR7WDO7.js → service-7BFXDI6J.js} +31 -13
- package/dist/{setup-52YRV7VP.js → setup-SSIIXQMI.js} +9 -34
- package/dist/{shared-KO35ZM44.js → shared-2OGT3NSL.js} +4 -4
- package/dist/{skill-BCVNI6TV.js → skill-Q2Y6PQ3L.js} +2 -2
- package/dist/skills/orientation/SKILL.md +2 -2
- package/dist/skills/volute-mind/SKILL.md +5 -5
- package/dist/sleep-manager-3RWUX2ZR.js +27 -0
- package/dist/{sprout-QN7Y4VVO.js → sprout-UKCYBGHK.js} +34 -30
- package/dist/{start-I5JYB65M.js → start-JR6CUUWF.js} +3 -6
- package/dist/{status-D7E5HHBV.js → status-5XDGYHKP.js} +2 -2
- package/dist/{status-4ESFLGH4.js → status-H2MKDN6L.js} +5 -5
- package/dist/{status-FU2PFVVF.js → status-LV34BG6G.js} +3 -3
- package/dist/{stop-NBVKEFQQ.js → stop-VKPGK25U.js} +2 -5
- package/dist/template-hash-BIMA4ILT.js +8 -0
- package/dist/{up-FS7CKM6V.js → up-JKGC7PPF.js} +5 -5
- package/dist/{update-FJIHDJKM.js → update-ELC6MEUT.js} +5 -5
- package/dist/{update-check-MWE5AH4U.js → update-check-F5Z3ALXX.js} +2 -2
- package/dist/{upgrade-AIT24B5I.js → upgrade-GXW2EQY3.js} +12 -3
- package/dist/{variant-63ZWO2W7.js → variant-A4I7PHXS.js} +16 -24
- package/dist/version-notify-5FGUAVSF.js +181 -0
- package/dist/web-assets/assets/index-DWBxl4LO.js +69 -0
- package/dist/web-assets/assets/index-ZqMd1mx1.css +1 -0
- package/dist/web-assets/index.html +2 -2
- package/package.json +10 -1
- package/templates/_base/.init/.config/prompts.json +1 -0
- package/templates/_base/home/.config/config.json.tmpl +4 -1
- package/templates/_base/src/lib/logger.ts +68 -23
- package/templates/_base/src/lib/startup.ts +12 -3
- package/templates/claude/src/agent.ts +150 -29
- package/templates/claude/src/lib/hooks/pre-compact.ts +18 -4
- package/templates/claude/src/lib/message-channel.ts +6 -0
- package/templates/claude/src/lib/stream-consumer.ts +7 -0
- package/templates/claude/src/server.ts +3 -1
- package/templates/pi/home/.config/config.json.tmpl +4 -1
- package/templates/pi/src/agent.ts +87 -0
- package/templates/pi/src/lib/event-handler.ts +13 -1
- package/templates/pi/src/server.ts +3 -1
- package/dist/chunk-5XNT2472.js +0 -36
- package/dist/chunk-FGSYHIS3.js +0 -891
- package/dist/chunk-UJ6GHNR7.js +0 -675
- package/dist/db-C2CJ46ZU.js +0 -10
- package/dist/delivery-manager-CSG7LXA4.js +0 -16
- package/dist/down-ZY35KMHR.js +0 -14
- package/dist/schema-GFH6RV3W.js +0 -26
- package/dist/variants-JAGWGBXG.js +0 -26
- package/dist/web-assets/assets/index-CUZTZzaW.js +0 -64
- package/dist/web-assets/assets/index-adVuCkqy.css +0 -1
|
@@ -0,0 +1,2651 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
readSystemsConfig
|
|
4
|
+
} from "./chunk-HFCBO2GL.js";
|
|
5
|
+
import {
|
|
6
|
+
markIdle
|
|
7
|
+
} from "./chunk-HGCDWKSP.js";
|
|
8
|
+
import {
|
|
9
|
+
publish,
|
|
10
|
+
subscribe
|
|
11
|
+
} from "./chunk-A4S7H6G6.js";
|
|
12
|
+
import {
|
|
13
|
+
RestartTracker,
|
|
14
|
+
RotatingLog,
|
|
15
|
+
clearJsonMap,
|
|
16
|
+
getMindManager,
|
|
17
|
+
getPrompt,
|
|
18
|
+
loadJsonMap,
|
|
19
|
+
saveJsonMap
|
|
20
|
+
} from "./chunk-VNVCRVYI.js";
|
|
21
|
+
import {
|
|
22
|
+
readVoluteConfig
|
|
23
|
+
} from "./chunk-XLC342FO.js";
|
|
24
|
+
import {
|
|
25
|
+
loadMergedEnv
|
|
26
|
+
} from "./chunk-PHU4DEAJ.js";
|
|
27
|
+
import {
|
|
28
|
+
deliveryQueue,
|
|
29
|
+
getDb,
|
|
30
|
+
mindHistory
|
|
31
|
+
} from "./chunk-SGPEZ32F.js";
|
|
32
|
+
import {
|
|
33
|
+
logger_default
|
|
34
|
+
} from "./chunk-YUIHSKR6.js";
|
|
35
|
+
import {
|
|
36
|
+
exec
|
|
37
|
+
} from "./chunk-JTDFJWI2.js";
|
|
38
|
+
import {
|
|
39
|
+
chownMindDir,
|
|
40
|
+
isIsolationEnabled,
|
|
41
|
+
wrapForIsolation
|
|
42
|
+
} from "./chunk-NWPT4ASZ.js";
|
|
43
|
+
import {
|
|
44
|
+
daemonLoopback,
|
|
45
|
+
findMind,
|
|
46
|
+
findVariant,
|
|
47
|
+
mindDir,
|
|
48
|
+
readRegistry,
|
|
49
|
+
stateDir,
|
|
50
|
+
voluteHome
|
|
51
|
+
} from "./chunk-B2CPS4QU.js";
|
|
52
|
+
|
|
53
|
+
// src/lib/daemon/sleep-manager.ts
|
|
54
|
+
import { execFile } from "child_process";
|
|
55
|
+
import {
|
|
56
|
+
existsSync as existsSync5,
|
|
57
|
+
mkdirSync as mkdirSync3,
|
|
58
|
+
readdirSync as readdirSync2,
|
|
59
|
+
readFileSync as readFileSync5,
|
|
60
|
+
readlinkSync,
|
|
61
|
+
renameSync,
|
|
62
|
+
writeFileSync as writeFileSync3
|
|
63
|
+
} from "fs";
|
|
64
|
+
import { resolve as resolve7 } from "path";
|
|
65
|
+
import { promisify } from "util";
|
|
66
|
+
import { CronExpressionParser as CronExpressionParser2 } from "cron-parser";
|
|
67
|
+
import { and as and2, eq as eq2, inArray } from "drizzle-orm";
|
|
68
|
+
|
|
69
|
+
// src/lib/pages-watcher.ts
|
|
70
|
+
import { existsSync, readdirSync, statSync, watch } from "fs";
|
|
71
|
+
import { join, resolve } from "path";
|
|
72
|
+
var watchers = /* @__PURE__ */ new Map();
|
|
73
|
+
var homeWatchers = /* @__PURE__ */ new Map();
|
|
74
|
+
var debounceTimers = /* @__PURE__ */ new Map();
|
|
75
|
+
var sitesCache = null;
|
|
76
|
+
var recentPagesCache = null;
|
|
77
|
+
function startPagesWatcher(mindName, pagesDir) {
|
|
78
|
+
try {
|
|
79
|
+
const watcher = watch(pagesDir, { recursive: true }, (_eventType, filename) => {
|
|
80
|
+
if (!filename || !filename.endsWith(".html")) return;
|
|
81
|
+
const key = `${mindName}:${filename}`;
|
|
82
|
+
const existing = debounceTimers.get(key);
|
|
83
|
+
if (existing) clearTimeout(existing);
|
|
84
|
+
debounceTimers.set(
|
|
85
|
+
key,
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
debounceTimers.delete(key);
|
|
88
|
+
invalidateCache();
|
|
89
|
+
publish({
|
|
90
|
+
type: "page_updated",
|
|
91
|
+
mind: mindName,
|
|
92
|
+
summary: `${mindName} updated ${filename}`,
|
|
93
|
+
metadata: { file: filename }
|
|
94
|
+
}).catch(
|
|
95
|
+
(err) => logger_default.error("failed to publish page_updated activity", logger_default.errorData(err))
|
|
96
|
+
);
|
|
97
|
+
}, 100)
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
watchers.set(mindName, watcher);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger_default.warn(`failed to start pages watcher for ${mindName}`, logger_default.errorData(err));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function startWatcher(mindName) {
|
|
106
|
+
if (watchers.has(mindName)) return;
|
|
107
|
+
const pagesDir = resolve(mindDir(mindName), "home", "pages");
|
|
108
|
+
if (existsSync(pagesDir)) {
|
|
109
|
+
startPagesWatcher(mindName, pagesDir);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (homeWatchers.has(mindName)) return;
|
|
113
|
+
const homeDir = resolve(mindDir(mindName), "home");
|
|
114
|
+
if (!existsSync(homeDir)) return;
|
|
115
|
+
try {
|
|
116
|
+
const hw = watch(homeDir, (_eventType, filename) => {
|
|
117
|
+
if (filename !== "pages") return;
|
|
118
|
+
if (!existsSync(pagesDir)) return;
|
|
119
|
+
hw.close();
|
|
120
|
+
homeWatchers.delete(mindName);
|
|
121
|
+
invalidateCache();
|
|
122
|
+
startPagesWatcher(mindName, pagesDir);
|
|
123
|
+
});
|
|
124
|
+
homeWatchers.set(mindName, hw);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
logger_default.warn(`failed to start home watcher for ${mindName}`, logger_default.errorData(err));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function stopWatcher(mindName) {
|
|
130
|
+
const watcher = watchers.get(mindName);
|
|
131
|
+
if (watcher) {
|
|
132
|
+
watcher.close();
|
|
133
|
+
watchers.delete(mindName);
|
|
134
|
+
}
|
|
135
|
+
const hw = homeWatchers.get(mindName);
|
|
136
|
+
if (hw) {
|
|
137
|
+
hw.close();
|
|
138
|
+
homeWatchers.delete(mindName);
|
|
139
|
+
}
|
|
140
|
+
for (const [key, timer] of debounceTimers) {
|
|
141
|
+
if (key.startsWith(`${mindName}:`)) {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
debounceTimers.delete(key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function stopAllWatchers() {
|
|
148
|
+
for (const [, watcher] of watchers) {
|
|
149
|
+
watcher.close();
|
|
150
|
+
}
|
|
151
|
+
watchers.clear();
|
|
152
|
+
for (const [, hw] of homeWatchers) {
|
|
153
|
+
hw.close();
|
|
154
|
+
}
|
|
155
|
+
homeWatchers.clear();
|
|
156
|
+
for (const [, timer] of debounceTimers) {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
debounceTimers.clear();
|
|
160
|
+
invalidateCache();
|
|
161
|
+
}
|
|
162
|
+
function invalidateCache() {
|
|
163
|
+
sitesCache = null;
|
|
164
|
+
recentPagesCache = null;
|
|
165
|
+
}
|
|
166
|
+
function scanPagesDir(dir, urlPrefix) {
|
|
167
|
+
const pages = [];
|
|
168
|
+
let items;
|
|
169
|
+
try {
|
|
170
|
+
items = readdirSync(dir);
|
|
171
|
+
} catch {
|
|
172
|
+
return pages;
|
|
173
|
+
}
|
|
174
|
+
for (const item of items) {
|
|
175
|
+
if (item.startsWith(".")) continue;
|
|
176
|
+
const fullPath = resolve(dir, item);
|
|
177
|
+
try {
|
|
178
|
+
const s = statSync(fullPath);
|
|
179
|
+
if (s.isFile() && item.endsWith(".html")) {
|
|
180
|
+
pages.push({
|
|
181
|
+
file: item,
|
|
182
|
+
modified: s.mtime.toISOString(),
|
|
183
|
+
url: `${urlPrefix}/${item}`
|
|
184
|
+
});
|
|
185
|
+
} else if (s.isDirectory()) {
|
|
186
|
+
const indexPath = resolve(fullPath, "index.html");
|
|
187
|
+
if (existsSync(indexPath)) {
|
|
188
|
+
const indexStat = statSync(indexPath);
|
|
189
|
+
pages.push({
|
|
190
|
+
file: join(item, "index.html"),
|
|
191
|
+
modified: indexStat.mtime.toISOString(),
|
|
192
|
+
url: `${urlPrefix}/${item}/`
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
pages.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
200
|
+
return pages;
|
|
201
|
+
}
|
|
202
|
+
function buildSites() {
|
|
203
|
+
const sites = [];
|
|
204
|
+
const systemPagesDir = resolve(voluteHome(), "shared", "pages");
|
|
205
|
+
if (existsSync(systemPagesDir)) {
|
|
206
|
+
const systemPages = scanPagesDir(systemPagesDir, "/pages/_system");
|
|
207
|
+
if (systemPages.length > 0) {
|
|
208
|
+
sites.push({ name: "_system", label: "System", pages: systemPages });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const entries = readRegistry();
|
|
212
|
+
for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
213
|
+
const pagesDir = resolve(mindDir(entry.name), "home", "pages");
|
|
214
|
+
if (!existsSync(pagesDir)) continue;
|
|
215
|
+
const mindPages = scanPagesDir(pagesDir, `/pages/${entry.name}`);
|
|
216
|
+
if (mindPages.length > 0) {
|
|
217
|
+
sites.push({ name: entry.name, label: entry.name, pages: mindPages });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return sites;
|
|
221
|
+
}
|
|
222
|
+
function buildRecentPages() {
|
|
223
|
+
const entries = readRegistry();
|
|
224
|
+
const pages = [];
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
const pagesDir = resolve(mindDir(entry.name), "home", "pages");
|
|
227
|
+
if (!existsSync(pagesDir)) continue;
|
|
228
|
+
let items;
|
|
229
|
+
try {
|
|
230
|
+
items = readdirSync(pagesDir);
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
for (const item of items) {
|
|
235
|
+
if (item.startsWith(".")) continue;
|
|
236
|
+
const fullPath = resolve(pagesDir, item);
|
|
237
|
+
try {
|
|
238
|
+
const s = statSync(fullPath);
|
|
239
|
+
if (s.isFile() && item.endsWith(".html")) {
|
|
240
|
+
pages.push({
|
|
241
|
+
mind: entry.name,
|
|
242
|
+
file: item,
|
|
243
|
+
modified: s.mtime.toISOString(),
|
|
244
|
+
url: `/pages/${entry.name}/${item}`
|
|
245
|
+
});
|
|
246
|
+
} else if (s.isDirectory()) {
|
|
247
|
+
const indexPath = resolve(fullPath, "index.html");
|
|
248
|
+
if (existsSync(indexPath)) {
|
|
249
|
+
const indexStat = statSync(indexPath);
|
|
250
|
+
pages.push({
|
|
251
|
+
mind: entry.name,
|
|
252
|
+
file: join(item, "index.html"),
|
|
253
|
+
modified: indexStat.mtime.toISOString(),
|
|
254
|
+
url: `/pages/${entry.name}/${item}/`
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
pages.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
263
|
+
return pages.slice(0, 10);
|
|
264
|
+
}
|
|
265
|
+
function getCachedSites() {
|
|
266
|
+
if (!sitesCache) sitesCache = buildSites();
|
|
267
|
+
return sitesCache;
|
|
268
|
+
}
|
|
269
|
+
function getCachedRecentPages() {
|
|
270
|
+
if (!recentPagesCache) recentPagesCache = buildRecentPages();
|
|
271
|
+
return recentPagesCache;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/lib/daemon/connector-manager.ts
|
|
275
|
+
import { spawn } from "child_process";
|
|
276
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
|
|
277
|
+
import { dirname, resolve as resolve3 } from "path";
|
|
278
|
+
|
|
279
|
+
// src/lib/connector-defs.ts
|
|
280
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
281
|
+
import { resolve as resolve2 } from "path";
|
|
282
|
+
var BUILTIN_DEFS = {
|
|
283
|
+
discord: {
|
|
284
|
+
displayName: "Discord",
|
|
285
|
+
description: "Connect to Discord as a bot",
|
|
286
|
+
envVars: [
|
|
287
|
+
{
|
|
288
|
+
name: "DISCORD_TOKEN",
|
|
289
|
+
required: true,
|
|
290
|
+
description: "Discord bot token",
|
|
291
|
+
scope: "mind"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "DISCORD_GUILD_ID",
|
|
295
|
+
required: false,
|
|
296
|
+
description: "Discord server ID (optional, for slash commands)",
|
|
297
|
+
scope: "mind"
|
|
298
|
+
}
|
|
299
|
+
]
|
|
300
|
+
},
|
|
301
|
+
slack: {
|
|
302
|
+
displayName: "Slack",
|
|
303
|
+
description: "Connect to Slack via Socket Mode",
|
|
304
|
+
envVars: [
|
|
305
|
+
{
|
|
306
|
+
name: "SLACK_BOT_TOKEN",
|
|
307
|
+
required: true,
|
|
308
|
+
description: "Slack bot token (xoxb-...)",
|
|
309
|
+
scope: "mind"
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: "SLACK_APP_TOKEN",
|
|
313
|
+
required: true,
|
|
314
|
+
description: "Slack app-level token (xapp-...) for Socket Mode",
|
|
315
|
+
scope: "mind"
|
|
316
|
+
}
|
|
317
|
+
]
|
|
318
|
+
},
|
|
319
|
+
telegram: {
|
|
320
|
+
displayName: "Telegram",
|
|
321
|
+
description: "Connect to Telegram via long polling",
|
|
322
|
+
envVars: [
|
|
323
|
+
{
|
|
324
|
+
name: "TELEGRAM_BOT_TOKEN",
|
|
325
|
+
required: true,
|
|
326
|
+
description: "Telegram bot token from BotFather",
|
|
327
|
+
scope: "mind"
|
|
328
|
+
}
|
|
329
|
+
]
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
function getConnectorDef(type, connectorDir) {
|
|
333
|
+
if (BUILTIN_DEFS[type]) return BUILTIN_DEFS[type];
|
|
334
|
+
if (connectorDir) {
|
|
335
|
+
const jsonPath = resolve2(connectorDir, "connector.json");
|
|
336
|
+
if (existsSync2(jsonPath)) {
|
|
337
|
+
try {
|
|
338
|
+
return JSON.parse(readFileSync(jsonPath, "utf-8"));
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.warn(`Failed to parse ${jsonPath}: ${err}`);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
function checkMissingEnvVars(def, env) {
|
|
348
|
+
return def.envVars.filter((v) => v.required && !env[v.name]);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/lib/daemon/connector-manager.ts
|
|
352
|
+
var clog = logger_default.child("connectors");
|
|
353
|
+
function searchUpwards(...segments) {
|
|
354
|
+
let searchDir = dirname(new URL(import.meta.url).pathname);
|
|
355
|
+
for (let i = 0; i < 5; i++) {
|
|
356
|
+
const candidate = resolve3(searchDir, ...segments);
|
|
357
|
+
if (existsSync3(candidate)) return candidate;
|
|
358
|
+
searchDir = dirname(searchDir);
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
var ConnectorManager = class {
|
|
363
|
+
connectors = /* @__PURE__ */ new Map();
|
|
364
|
+
stopping = /* @__PURE__ */ new Set();
|
|
365
|
+
// "mind:type" keys currently being explicitly stopped
|
|
366
|
+
shuttingDown = false;
|
|
367
|
+
restartTracker = new RestartTracker();
|
|
368
|
+
async startConnectors(mindName, mindDir2, mindPort, daemonPort) {
|
|
369
|
+
const config = readVoluteConfig(mindDir2) ?? {};
|
|
370
|
+
const types = config.connectors ?? [];
|
|
371
|
+
await Promise.all(
|
|
372
|
+
types.map(
|
|
373
|
+
(type) => this.startConnector(mindName, mindDir2, mindPort, type, daemonPort).catch((err) => {
|
|
374
|
+
clog.warn(`failed to start connector ${type} for ${mindName}`, logger_default.errorData(err));
|
|
375
|
+
})
|
|
376
|
+
)
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
checkConnectorEnv(type, mindName, mindDir2) {
|
|
380
|
+
const mindConnectorDir = resolve3(mindDir2, "connectors", type);
|
|
381
|
+
const userConnectorDir = resolve3(voluteHome(), "connectors", type);
|
|
382
|
+
const connectorDir = existsSync3(mindConnectorDir) ? mindConnectorDir : existsSync3(userConnectorDir) ? userConnectorDir : void 0;
|
|
383
|
+
const def = getConnectorDef(type, connectorDir);
|
|
384
|
+
if (!def) return null;
|
|
385
|
+
const env = loadMergedEnv(mindName);
|
|
386
|
+
const missing = checkMissingEnvVars(def, env);
|
|
387
|
+
if (missing.length === 0) return null;
|
|
388
|
+
return {
|
|
389
|
+
missing: missing.map((v) => ({ name: v.name, description: v.description })),
|
|
390
|
+
connectorName: def.displayName
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
async startConnector(mindName, mindDir2, mindPort, type, daemonPort) {
|
|
394
|
+
const existing = this.connectors.get(mindName)?.get(type);
|
|
395
|
+
if (existing) {
|
|
396
|
+
await new Promise((res) => {
|
|
397
|
+
existing.child.on("exit", () => res());
|
|
398
|
+
try {
|
|
399
|
+
if (existing.child.pid) {
|
|
400
|
+
process.kill(-existing.child.pid, "SIGTERM");
|
|
401
|
+
} else {
|
|
402
|
+
existing.child.kill("SIGTERM");
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
res();
|
|
406
|
+
}
|
|
407
|
+
setTimeout(() => {
|
|
408
|
+
try {
|
|
409
|
+
if (existing.child.pid) {
|
|
410
|
+
process.kill(-existing.child.pid, "SIGKILL");
|
|
411
|
+
} else {
|
|
412
|
+
existing.child.kill("SIGKILL");
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
}
|
|
416
|
+
res();
|
|
417
|
+
}, 3e3);
|
|
418
|
+
});
|
|
419
|
+
this.connectors.get(mindName)?.delete(type);
|
|
420
|
+
}
|
|
421
|
+
this.killOrphanConnector(mindName, type);
|
|
422
|
+
const mindConnector = resolve3(mindDir2, "connectors", type, "index.ts");
|
|
423
|
+
const userConnector = resolve3(voluteHome(), "connectors", type, "index.ts");
|
|
424
|
+
const builtinConnector = this.resolveBuiltinConnector(type);
|
|
425
|
+
let connectorScript;
|
|
426
|
+
let runtime;
|
|
427
|
+
if (existsSync3(mindConnector)) {
|
|
428
|
+
connectorScript = mindConnector;
|
|
429
|
+
runtime = resolve3(mindDir2, "node_modules", ".bin", "tsx");
|
|
430
|
+
} else if (existsSync3(userConnector)) {
|
|
431
|
+
connectorScript = userConnector;
|
|
432
|
+
runtime = this.resolveVoluteTsx();
|
|
433
|
+
} else if (builtinConnector) {
|
|
434
|
+
connectorScript = builtinConnector;
|
|
435
|
+
runtime = process.execPath;
|
|
436
|
+
} else {
|
|
437
|
+
throw new Error(`No connector code found for type: ${type}`);
|
|
438
|
+
}
|
|
439
|
+
const mindStateDir = stateDir(mindName);
|
|
440
|
+
const logsDir = resolve3(mindStateDir, "logs");
|
|
441
|
+
mkdirSync(logsDir, { recursive: true });
|
|
442
|
+
if (isIsolationEnabled()) {
|
|
443
|
+
try {
|
|
444
|
+
const [base] = mindName.split("@", 2);
|
|
445
|
+
chownMindDir(mindStateDir, base);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`Cannot start connector ${type} for ${mindName}: failed to set ownership on state directory ${mindStateDir}: ${err instanceof Error ? err.message : err}`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const logStream = new RotatingLog(resolve3(logsDir, `${type}.log`));
|
|
453
|
+
const mindEnv = loadMergedEnv(mindName);
|
|
454
|
+
const prefix = `${type.toUpperCase()}_`;
|
|
455
|
+
const connectorEnv = Object.fromEntries(
|
|
456
|
+
Object.entries(mindEnv).filter(([k]) => k.startsWith(prefix))
|
|
457
|
+
);
|
|
458
|
+
const spawnOpts = {
|
|
459
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
460
|
+
detached: true,
|
|
461
|
+
env: {
|
|
462
|
+
...process.env,
|
|
463
|
+
VOLUTE_MIND_PORT: String(mindPort),
|
|
464
|
+
VOLUTE_MIND_NAME: mindName,
|
|
465
|
+
VOLUTE_MIND_DIR: mindDir2,
|
|
466
|
+
...daemonPort ? {
|
|
467
|
+
VOLUTE_DAEMON_URL: `http://${daemonLoopback()}:${daemonPort}`,
|
|
468
|
+
VOLUTE_DAEMON_TOKEN: process.env.VOLUTE_DAEMON_TOKEN
|
|
469
|
+
} : {},
|
|
470
|
+
...connectorEnv
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const [spawnCmd, spawnArgs] = wrapForIsolation(runtime, [connectorScript], mindName);
|
|
474
|
+
const child = spawn(spawnCmd, spawnArgs, spawnOpts);
|
|
475
|
+
let lastStderr = "";
|
|
476
|
+
child.stdout?.pipe(logStream);
|
|
477
|
+
child.stderr?.on("data", (chunk) => {
|
|
478
|
+
logStream.write(chunk);
|
|
479
|
+
lastStderr = chunk.toString().trim();
|
|
480
|
+
});
|
|
481
|
+
if (child.pid) {
|
|
482
|
+
this.saveConnectorPid(mindName, type, child.pid);
|
|
483
|
+
}
|
|
484
|
+
if (!this.connectors.has(mindName)) {
|
|
485
|
+
this.connectors.set(mindName, /* @__PURE__ */ new Map());
|
|
486
|
+
}
|
|
487
|
+
this.connectors.get(mindName).set(type, { child, type });
|
|
488
|
+
const stopKey = `${mindName}:${type}`;
|
|
489
|
+
this.restartTracker.reset(stopKey);
|
|
490
|
+
child.on("exit", (code) => {
|
|
491
|
+
const mindMap = this.connectors.get(mindName);
|
|
492
|
+
if (mindMap?.get(type)?.child === child) {
|
|
493
|
+
mindMap.delete(type);
|
|
494
|
+
}
|
|
495
|
+
if (this.shuttingDown) return;
|
|
496
|
+
if (this.stopping.has(stopKey)) return;
|
|
497
|
+
clog.error(`connector ${type} for ${mindName} exited with code ${code}`);
|
|
498
|
+
if (lastStderr) clog.warn(`connector ${type} last output: ${lastStderr}`);
|
|
499
|
+
const { shouldRestart, delay, attempt } = this.restartTracker.recordCrash(stopKey);
|
|
500
|
+
if (!shouldRestart) {
|
|
501
|
+
clog.error(`connector ${type} for ${mindName} crashed ${attempt} times \u2014 giving up`);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
clog.info(
|
|
505
|
+
`restarting connector ${type} for ${mindName} \u2014 attempt ${attempt}/${this.restartTracker.maxRestartAttempts}, in ${delay}ms`
|
|
506
|
+
);
|
|
507
|
+
setTimeout(() => {
|
|
508
|
+
if (this.shuttingDown || this.stopping.has(stopKey)) return;
|
|
509
|
+
this.startConnector(mindName, mindDir2, mindPort, type, daemonPort).catch((err) => {
|
|
510
|
+
clog.error(`failed to restart connector ${type} for ${mindName}`, logger_default.errorData(err));
|
|
511
|
+
});
|
|
512
|
+
}, delay);
|
|
513
|
+
});
|
|
514
|
+
clog.info(`started connector ${type} for ${mindName}`);
|
|
515
|
+
}
|
|
516
|
+
async stopConnector(mindName, type) {
|
|
517
|
+
const mindMap = this.connectors.get(mindName);
|
|
518
|
+
if (!mindMap) return;
|
|
519
|
+
const tracked = mindMap.get(type);
|
|
520
|
+
if (!tracked) return;
|
|
521
|
+
const stopKey = `${mindName}:${type}`;
|
|
522
|
+
this.stopping.add(stopKey);
|
|
523
|
+
mindMap.delete(type);
|
|
524
|
+
await new Promise((resolve8) => {
|
|
525
|
+
tracked.child.on("exit", () => resolve8());
|
|
526
|
+
try {
|
|
527
|
+
process.kill(-tracked.child.pid, "SIGTERM");
|
|
528
|
+
} catch {
|
|
529
|
+
resolve8();
|
|
530
|
+
}
|
|
531
|
+
setTimeout(() => {
|
|
532
|
+
try {
|
|
533
|
+
process.kill(-tracked.child.pid, "SIGKILL");
|
|
534
|
+
} catch {
|
|
535
|
+
}
|
|
536
|
+
resolve8();
|
|
537
|
+
}, 5e3);
|
|
538
|
+
});
|
|
539
|
+
this.stopping.delete(stopKey);
|
|
540
|
+
this.restartTracker.reset(stopKey);
|
|
541
|
+
try {
|
|
542
|
+
this.removeConnectorPid(mindName, type);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
clog.warn(`failed to remove PID file for ${type}/${mindName}`, logger_default.errorData(err));
|
|
545
|
+
}
|
|
546
|
+
clog.info(`stopped connector ${type} for ${mindName}`);
|
|
547
|
+
}
|
|
548
|
+
async stopConnectors(mindName) {
|
|
549
|
+
const mindMap = this.connectors.get(mindName);
|
|
550
|
+
if (!mindMap) return;
|
|
551
|
+
const types = [...mindMap.keys()];
|
|
552
|
+
await Promise.all(types.map((type) => this.stopConnector(mindName, type)));
|
|
553
|
+
this.connectors.delete(mindName);
|
|
554
|
+
}
|
|
555
|
+
async stopAll() {
|
|
556
|
+
this.shuttingDown = true;
|
|
557
|
+
const minds = [...this.connectors.keys()];
|
|
558
|
+
await Promise.all(minds.map((name) => this.stopConnectors(name)));
|
|
559
|
+
}
|
|
560
|
+
getConnectorStatus(mindName) {
|
|
561
|
+
const mindMap = this.connectors.get(mindName);
|
|
562
|
+
if (!mindMap) return [];
|
|
563
|
+
return [...mindMap.entries()].map(([type, tracked]) => ({
|
|
564
|
+
type,
|
|
565
|
+
running: !tracked.child.killed
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
connectorPidPath(mindName, type) {
|
|
569
|
+
return resolve3(stateDir(mindName), "connectors", `${type}.pid`);
|
|
570
|
+
}
|
|
571
|
+
saveConnectorPid(mindName, type, pid) {
|
|
572
|
+
const pidPath = this.connectorPidPath(mindName, type);
|
|
573
|
+
mkdirSync(dirname(pidPath), { recursive: true });
|
|
574
|
+
writeFileSync(pidPath, String(pid));
|
|
575
|
+
}
|
|
576
|
+
removeConnectorPid(mindName, type) {
|
|
577
|
+
try {
|
|
578
|
+
unlinkSync(this.connectorPidPath(mindName, type));
|
|
579
|
+
} catch {
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
killOrphanConnector(mindName, type) {
|
|
583
|
+
const pidPath = this.connectorPidPath(mindName, type);
|
|
584
|
+
if (!existsSync3(pidPath)) return;
|
|
585
|
+
try {
|
|
586
|
+
const pid = parseInt(readFileSync2(pidPath, "utf-8").trim(), 10);
|
|
587
|
+
if (pid > 0) {
|
|
588
|
+
try {
|
|
589
|
+
process.kill(-pid, "SIGTERM");
|
|
590
|
+
} catch {
|
|
591
|
+
process.kill(pid, "SIGTERM");
|
|
592
|
+
}
|
|
593
|
+
clog.warn(`killed orphan connector ${type} (pid ${pid})`);
|
|
594
|
+
}
|
|
595
|
+
} catch {
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
unlinkSync(pidPath);
|
|
599
|
+
} catch {
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
resolveBuiltinConnector(type) {
|
|
603
|
+
return searchUpwards("connectors", `${type}.js`);
|
|
604
|
+
}
|
|
605
|
+
resolveVoluteTsx() {
|
|
606
|
+
return searchUpwards("node_modules", ".bin", "tsx") ?? "tsx";
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
var instance = null;
|
|
610
|
+
function initConnectorManager() {
|
|
611
|
+
if (instance) throw new Error("ConnectorManager already initialized");
|
|
612
|
+
instance = new ConnectorManager();
|
|
613
|
+
return instance;
|
|
614
|
+
}
|
|
615
|
+
function getConnectorManager() {
|
|
616
|
+
if (!instance)
|
|
617
|
+
throw new Error("ConnectorManager not initialized \u2014 call initConnectorManager() first");
|
|
618
|
+
return instance;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/lib/delivery/delivery-manager.ts
|
|
622
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
623
|
+
|
|
624
|
+
// src/lib/events/conversation-events.ts
|
|
625
|
+
var subscribers = /* @__PURE__ */ new Map();
|
|
626
|
+
function subscribe2(conversationId, callback) {
|
|
627
|
+
let set = subscribers.get(conversationId);
|
|
628
|
+
if (!set) {
|
|
629
|
+
set = /* @__PURE__ */ new Set();
|
|
630
|
+
subscribers.set(conversationId, set);
|
|
631
|
+
}
|
|
632
|
+
set.add(callback);
|
|
633
|
+
return () => {
|
|
634
|
+
set.delete(callback);
|
|
635
|
+
if (set.size === 0) subscribers.delete(conversationId);
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function publish2(conversationId, event) {
|
|
639
|
+
const set = subscribers.get(conversationId);
|
|
640
|
+
if (!set) return;
|
|
641
|
+
for (const cb of set) {
|
|
642
|
+
try {
|
|
643
|
+
cb(event);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
console.error("[conversation-events] subscriber threw:", err);
|
|
646
|
+
set.delete(cb);
|
|
647
|
+
if (set.size === 0) subscribers.delete(conversationId);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/lib/typing.ts
|
|
653
|
+
var DEFAULT_TTL_MS = 1e4;
|
|
654
|
+
var SWEEP_INTERVAL_MS = 5e3;
|
|
655
|
+
var VOLUTE_PREFIX = "volute:";
|
|
656
|
+
var TypingMap = class {
|
|
657
|
+
channels = /* @__PURE__ */ new Map();
|
|
658
|
+
sweepTimer;
|
|
659
|
+
constructor() {
|
|
660
|
+
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
|
661
|
+
this.sweepTimer.unref();
|
|
662
|
+
}
|
|
663
|
+
set(channel, sender, opts) {
|
|
664
|
+
const expiresAt = opts?.persistent ? Infinity : Date.now() + (opts?.ttlMs ?? DEFAULT_TTL_MS);
|
|
665
|
+
let senders = this.channels.get(channel);
|
|
666
|
+
if (!senders) {
|
|
667
|
+
senders = /* @__PURE__ */ new Map();
|
|
668
|
+
this.channels.set(channel, senders);
|
|
669
|
+
}
|
|
670
|
+
senders.set(sender, { expiresAt });
|
|
671
|
+
}
|
|
672
|
+
delete(channel, sender) {
|
|
673
|
+
const senders = this.channels.get(channel);
|
|
674
|
+
if (senders) {
|
|
675
|
+
senders.delete(sender);
|
|
676
|
+
if (senders.size === 0) {
|
|
677
|
+
this.channels.delete(channel);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/** Remove a sender from all channels (e.g. when a mind finishes processing). Returns affected channel names. */
|
|
682
|
+
deleteSender(sender) {
|
|
683
|
+
const affected = [];
|
|
684
|
+
for (const [channel, senders] of this.channels) {
|
|
685
|
+
if (senders.has(sender)) {
|
|
686
|
+
senders.delete(sender);
|
|
687
|
+
affected.push(channel);
|
|
688
|
+
}
|
|
689
|
+
if (senders.size === 0) {
|
|
690
|
+
this.channels.delete(channel);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return affected;
|
|
694
|
+
}
|
|
695
|
+
get(channel) {
|
|
696
|
+
const senders = this.channels.get(channel);
|
|
697
|
+
if (!senders) return [];
|
|
698
|
+
const now = Date.now();
|
|
699
|
+
const result = [];
|
|
700
|
+
for (const [sender, entry] of senders) {
|
|
701
|
+
if (entry.expiresAt > now) {
|
|
702
|
+
result.push(sender);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
dispose() {
|
|
708
|
+
clearInterval(this.sweepTimer);
|
|
709
|
+
this.channels.clear();
|
|
710
|
+
if (instance2 === this) instance2 = void 0;
|
|
711
|
+
}
|
|
712
|
+
sweep() {
|
|
713
|
+
const now = Date.now();
|
|
714
|
+
for (const [channel, senders] of this.channels) {
|
|
715
|
+
for (const [sender, entry] of senders) {
|
|
716
|
+
if (entry.expiresAt <= now) {
|
|
717
|
+
senders.delete(sender);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (senders.size === 0) {
|
|
721
|
+
this.channels.delete(channel);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
var instance2;
|
|
727
|
+
function getTypingMap() {
|
|
728
|
+
if (!instance2) {
|
|
729
|
+
instance2 = new TypingMap();
|
|
730
|
+
}
|
|
731
|
+
return instance2;
|
|
732
|
+
}
|
|
733
|
+
function publishTypingForChannels(channels, map) {
|
|
734
|
+
for (const channel of channels) {
|
|
735
|
+
if (channel.startsWith(VOLUTE_PREFIX)) {
|
|
736
|
+
const conversationId = channel.slice(VOLUTE_PREFIX.length);
|
|
737
|
+
publish2(conversationId, { type: "typing", senders: map.get(channel) });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/lib/delivery/delivery-router.ts
|
|
743
|
+
import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
|
|
744
|
+
import { resolve as resolve4 } from "path";
|
|
745
|
+
function extractTextContent(content) {
|
|
746
|
+
if (typeof content === "string") return content;
|
|
747
|
+
if (Array.isArray(content)) {
|
|
748
|
+
return content.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
|
|
749
|
+
}
|
|
750
|
+
return JSON.stringify(content);
|
|
751
|
+
}
|
|
752
|
+
var configCache = /* @__PURE__ */ new Map();
|
|
753
|
+
var statCheckCache = /* @__PURE__ */ new Map();
|
|
754
|
+
var STAT_TTL_MS = 5e3;
|
|
755
|
+
var dlog = logger_default.child("delivery-router");
|
|
756
|
+
function configPath(mindName) {
|
|
757
|
+
return resolve4(mindDir(mindName), "home/.config/routes.json");
|
|
758
|
+
}
|
|
759
|
+
function getRoutingConfig(mindName) {
|
|
760
|
+
const path = configPath(mindName);
|
|
761
|
+
const now = Date.now();
|
|
762
|
+
const statCached = statCheckCache.get(mindName);
|
|
763
|
+
const cached = configCache.get(mindName);
|
|
764
|
+
if (statCached && cached && now - statCached.checkedAt < STAT_TTL_MS) {
|
|
765
|
+
return cached.config;
|
|
766
|
+
}
|
|
767
|
+
let mtime;
|
|
768
|
+
try {
|
|
769
|
+
mtime = statSync2(path).mtimeMs;
|
|
770
|
+
} catch {
|
|
771
|
+
configCache.delete(mindName);
|
|
772
|
+
statCheckCache.delete(mindName);
|
|
773
|
+
return {};
|
|
774
|
+
}
|
|
775
|
+
statCheckCache.set(mindName, { mtime, checkedAt: now });
|
|
776
|
+
if (cached && cached.mtime === mtime) {
|
|
777
|
+
return cached.config;
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
const parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
781
|
+
const config = Array.isArray(parsed) ? { rules: parsed } : parsed;
|
|
782
|
+
configCache.set(mindName, { config, mtime });
|
|
783
|
+
return config;
|
|
784
|
+
} catch (err) {
|
|
785
|
+
dlog.warn(`failed to load routes.json for ${mindName}`, logger_default.errorData(err));
|
|
786
|
+
configCache.delete(mindName);
|
|
787
|
+
return {};
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
var globRegexCache = /* @__PURE__ */ new Map();
|
|
791
|
+
function globMatch(pattern, value) {
|
|
792
|
+
let regex = globRegexCache.get(pattern);
|
|
793
|
+
if (!regex) {
|
|
794
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
795
|
+
regex = new RegExp(`^${escaped}$`);
|
|
796
|
+
globRegexCache.set(pattern, regex);
|
|
797
|
+
}
|
|
798
|
+
return regex.test(value);
|
|
799
|
+
}
|
|
800
|
+
var GLOB_MATCH_KEYS = /* @__PURE__ */ new Set(["channel", "sender"]);
|
|
801
|
+
var NON_MATCH_KEYS = /* @__PURE__ */ new Set(["session", "destination", "path", "mode"]);
|
|
802
|
+
function ruleMatches(rule, meta) {
|
|
803
|
+
for (const [key, pattern] of Object.entries(rule)) {
|
|
804
|
+
if (NON_MATCH_KEYS.has(key)) continue;
|
|
805
|
+
if (key === "isDM") {
|
|
806
|
+
if (typeof pattern !== "boolean") return false;
|
|
807
|
+
if ((meta.isDM ?? false) !== pattern) return false;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (key === "participants") {
|
|
811
|
+
if (typeof pattern !== "number") return false;
|
|
812
|
+
if ((meta.participantCount ?? 0) !== pattern) return false;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (typeof pattern !== "string") return false;
|
|
816
|
+
if (!GLOB_MATCH_KEYS.has(key)) return false;
|
|
817
|
+
const value = meta[key] ?? "";
|
|
818
|
+
if (!globMatch(pattern, value)) return false;
|
|
819
|
+
}
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
function expandTemplate(template, meta) {
|
|
823
|
+
return template.replace(/\$\{sender\}/g, meta.sender ?? "unknown").replace(/\$\{channel\}/g, meta.channel ?? "unknown");
|
|
824
|
+
}
|
|
825
|
+
function sanitizeSessionName(name) {
|
|
826
|
+
return name.replace(/\0/g, "").replace(/[/\\]/g, "-").replace(/\.\./g, "-").slice(0, 100);
|
|
827
|
+
}
|
|
828
|
+
function resolveRoute(config, meta) {
|
|
829
|
+
const fallback = config.default ?? "main";
|
|
830
|
+
if (!config.rules) {
|
|
831
|
+
return { destination: "mind", session: fallback, matched: false };
|
|
832
|
+
}
|
|
833
|
+
for (const rule of config.rules) {
|
|
834
|
+
if (ruleMatches(rule, meta)) {
|
|
835
|
+
if (rule.destination === "file") {
|
|
836
|
+
if (!rule.path) {
|
|
837
|
+
dlog.warn("file destination rule missing path \u2014 falling through");
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
return { destination: "file", path: rule.path, matched: true };
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
destination: "mind",
|
|
844
|
+
session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
|
|
845
|
+
matched: true,
|
|
846
|
+
mode: rule.mode
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return { destination: "mind", session: fallback, matched: false };
|
|
851
|
+
}
|
|
852
|
+
var DEFAULT_BATCH_DEBOUNCE = 5;
|
|
853
|
+
var DEFAULT_BATCH_MAX_WAIT = 120;
|
|
854
|
+
function normalizeBatchConfig(batch) {
|
|
855
|
+
if (typeof batch === "number") return { maxWait: batch * 60 };
|
|
856
|
+
return batch;
|
|
857
|
+
}
|
|
858
|
+
function resolveDeliveryMode(config, sessionName) {
|
|
859
|
+
const defaults = {
|
|
860
|
+
delivery: { mode: "immediate" },
|
|
861
|
+
interrupt: true
|
|
862
|
+
};
|
|
863
|
+
if (!config.sessions) return defaults;
|
|
864
|
+
for (const [pattern, sessionConfig] of Object.entries(config.sessions)) {
|
|
865
|
+
if (globMatch(pattern, sessionName)) {
|
|
866
|
+
let delivery;
|
|
867
|
+
if (sessionConfig.delivery != null) {
|
|
868
|
+
if (sessionConfig.delivery === "immediate") {
|
|
869
|
+
delivery = { mode: "immediate" };
|
|
870
|
+
} else if (sessionConfig.delivery === "batch") {
|
|
871
|
+
delivery = {
|
|
872
|
+
mode: "batch",
|
|
873
|
+
debounce: DEFAULT_BATCH_DEBOUNCE,
|
|
874
|
+
maxWait: DEFAULT_BATCH_MAX_WAIT
|
|
875
|
+
};
|
|
876
|
+
} else {
|
|
877
|
+
delivery = {
|
|
878
|
+
mode: "batch",
|
|
879
|
+
debounce: sessionConfig.delivery.debounce ?? DEFAULT_BATCH_DEBOUNCE,
|
|
880
|
+
maxWait: sessionConfig.delivery.maxWait ?? DEFAULT_BATCH_MAX_WAIT
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
} else if (sessionConfig.batch != null) {
|
|
884
|
+
const batch = normalizeBatchConfig(sessionConfig.batch);
|
|
885
|
+
delivery = {
|
|
886
|
+
mode: "batch",
|
|
887
|
+
debounce: batch.debounce ?? DEFAULT_BATCH_DEBOUNCE,
|
|
888
|
+
maxWait: batch.maxWait ?? DEFAULT_BATCH_MAX_WAIT,
|
|
889
|
+
triggers: batch.triggers
|
|
890
|
+
};
|
|
891
|
+
} else if (sessionConfig.interrupt === false) {
|
|
892
|
+
delivery = {
|
|
893
|
+
mode: "batch",
|
|
894
|
+
debounce: DEFAULT_BATCH_DEBOUNCE,
|
|
895
|
+
maxWait: DEFAULT_BATCH_MAX_WAIT
|
|
896
|
+
};
|
|
897
|
+
} else {
|
|
898
|
+
delivery = { mode: "immediate" };
|
|
899
|
+
}
|
|
900
|
+
return {
|
|
901
|
+
delivery,
|
|
902
|
+
interrupt: sessionConfig.interrupt ?? true,
|
|
903
|
+
instructions: sessionConfig.instructions
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return defaults;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// src/lib/delivery/delivery-manager.ts
|
|
911
|
+
var dlog2 = logger_default.child("delivery-manager");
|
|
912
|
+
var MAX_BATCH_SIZE = 50;
|
|
913
|
+
var DeliveryManager = class {
|
|
914
|
+
sessionStates = /* @__PURE__ */ new Map();
|
|
915
|
+
batchBuffers = /* @__PURE__ */ new Map();
|
|
916
|
+
// --- Public API ---
|
|
917
|
+
/**
|
|
918
|
+
* Route and deliver a message to a mind. This is the main entry point.
|
|
919
|
+
* The message is routed via the mind's routes.json, then either delivered immediately
|
|
920
|
+
* or queued for batching depending on the session's delivery mode.
|
|
921
|
+
*/
|
|
922
|
+
async routeAndDeliver(mindName, payload) {
|
|
923
|
+
const [baseName] = mindName.split("@", 2);
|
|
924
|
+
const config = getRoutingConfig(baseName);
|
|
925
|
+
const meta = {
|
|
926
|
+
channel: payload.channel,
|
|
927
|
+
sender: payload.sender ?? void 0,
|
|
928
|
+
isDM: payload.isDM,
|
|
929
|
+
participantCount: payload.participantCount
|
|
930
|
+
};
|
|
931
|
+
const route = resolveRoute(config, meta);
|
|
932
|
+
dlog2.debug(
|
|
933
|
+
`route for ${mindName} ch=${payload.channel}: dest=${route.destination} matched=${route.matched}`
|
|
934
|
+
);
|
|
935
|
+
if (route.destination === "file") {
|
|
936
|
+
return { routed: true, session: route.path, destination: "file", mode: "immediate" };
|
|
937
|
+
}
|
|
938
|
+
if (!route.matched && config.gateUnmatched !== false) {
|
|
939
|
+
dlog2.debug(`gating unmatched channel ${payload.channel} for ${mindName}`);
|
|
940
|
+
await this.gateMessage(mindName, route.session, payload);
|
|
941
|
+
return { routed: true, session: route.session, destination: "mind", mode: "gated" };
|
|
942
|
+
}
|
|
943
|
+
if (route.mode === "mention" && payload.sender) {
|
|
944
|
+
const text = extractTextContent(payload.content);
|
|
945
|
+
const escaped = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
946
|
+
const pattern = new RegExp(`\\b${escaped}\\b`, "i");
|
|
947
|
+
if (!pattern.test(text)) {
|
|
948
|
+
dlog2.debug(`mention-filtered message on ${payload.channel} for ${mindName}`);
|
|
949
|
+
return { routed: false, reason: "mention-filtered" };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
let sessionName = route.session;
|
|
953
|
+
if (sessionName === "$new") {
|
|
954
|
+
sessionName = `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
955
|
+
}
|
|
956
|
+
const sessionConfig = resolveDeliveryMode(config, sessionName);
|
|
957
|
+
if (sessionConfig.delivery.mode === "batch") {
|
|
958
|
+
dlog2.debug(`enqueueing batch message for ${mindName}/${sessionName}`);
|
|
959
|
+
this.enqueueBatch(mindName, sessionName, payload, sessionConfig);
|
|
960
|
+
return { routed: true, session: sessionName, destination: "mind", mode: "batch" };
|
|
961
|
+
}
|
|
962
|
+
await this.deliverToMind(mindName, sessionName, payload, sessionConfig);
|
|
963
|
+
return { routed: true, session: sessionName, destination: "mind", mode: "immediate" };
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Called when a mind's session emits a "done" event — decrements active count
|
|
967
|
+
* and may trigger batch flush if session goes idle.
|
|
968
|
+
*/
|
|
969
|
+
sessionDone(mindName, session) {
|
|
970
|
+
const [baseName] = mindName.split("@", 2);
|
|
971
|
+
if (session) {
|
|
972
|
+
this.decrementActive(baseName, session);
|
|
973
|
+
} else {
|
|
974
|
+
const mindSessions = this.sessionStates.get(baseName);
|
|
975
|
+
if (mindSessions) {
|
|
976
|
+
for (const [sessionName] of mindSessions) {
|
|
977
|
+
this.decrementActive(baseName, sessionName);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Restore queued messages from DB on daemon restart.
|
|
984
|
+
*/
|
|
985
|
+
async restoreFromDb() {
|
|
986
|
+
try {
|
|
987
|
+
const db = await getDb();
|
|
988
|
+
const rows = await db.select().from(deliveryQueue).where(eq(deliveryQueue.status, "pending"));
|
|
989
|
+
for (const row of rows) {
|
|
990
|
+
let payload;
|
|
991
|
+
try {
|
|
992
|
+
payload = JSON.parse(row.payload);
|
|
993
|
+
} catch (parseErr) {
|
|
994
|
+
dlog2.warn(
|
|
995
|
+
`corrupt payload in delivery queue row ${row.id}, skipping`,
|
|
996
|
+
logger_default.errorData(parseErr)
|
|
997
|
+
);
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
const config = getRoutingConfig(row.mind);
|
|
1001
|
+
const sessionConfig = resolveDeliveryMode(config, row.session);
|
|
1002
|
+
if (sessionConfig.delivery.mode === "batch") {
|
|
1003
|
+
this.addToBatchBuffer(row.mind, row.session, payload, sessionConfig);
|
|
1004
|
+
} else {
|
|
1005
|
+
try {
|
|
1006
|
+
await db.delete(deliveryQueue).where(eq(deliveryQueue.id, row.id));
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
dlog2.warn(`failed to delete queue row ${row.id} for ${row.mind}`, logger_default.errorData(err));
|
|
1009
|
+
}
|
|
1010
|
+
this.deliverToMind(row.mind, row.session, payload, sessionConfig).catch((err) => {
|
|
1011
|
+
dlog2.warn(`failed to restore delivery for ${row.mind}`, logger_default.errorData(err));
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (rows.length > 0) {
|
|
1016
|
+
dlog2.info(`restored ${rows.length} queued messages from DB`);
|
|
1017
|
+
}
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
dlog2.warn("failed to restore delivery queue from DB", logger_default.errorData(err));
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Get pending (gated) messages for a mind.
|
|
1024
|
+
*/
|
|
1025
|
+
async getPending(mindName) {
|
|
1026
|
+
const db = await getDb();
|
|
1027
|
+
const rows = await db.select().from(deliveryQueue).where(and(eq(deliveryQueue.mind, mindName), eq(deliveryQueue.status, "gated")));
|
|
1028
|
+
const byChannel = /* @__PURE__ */ new Map();
|
|
1029
|
+
for (const row of rows) {
|
|
1030
|
+
const ch = row.channel ?? "unknown";
|
|
1031
|
+
const existing = byChannel.get(ch) ?? [];
|
|
1032
|
+
existing.push(row);
|
|
1033
|
+
byChannel.set(ch, existing);
|
|
1034
|
+
}
|
|
1035
|
+
return [...byChannel.entries()].map(([channel, channelRows]) => {
|
|
1036
|
+
const firstRow = channelRows[0];
|
|
1037
|
+
const payload = JSON.parse(firstRow.payload);
|
|
1038
|
+
const text = extractTextContent(payload.content);
|
|
1039
|
+
return {
|
|
1040
|
+
channel,
|
|
1041
|
+
sender: firstRow.sender,
|
|
1042
|
+
count: channelRows.length,
|
|
1043
|
+
firstSeen: firstRow.created_at,
|
|
1044
|
+
preview: text.length > 200 ? `${text.slice(0, 200)}...` : text
|
|
1045
|
+
};
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Check if a session is currently busy (has active deliveries).
|
|
1050
|
+
*/
|
|
1051
|
+
isSessionBusy(mindName, session) {
|
|
1052
|
+
const state = this.sessionStates.get(mindName)?.get(session);
|
|
1053
|
+
return (state?.activeCount ?? 0) > 0;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Cleanup all timers and subscriptions.
|
|
1057
|
+
*/
|
|
1058
|
+
dispose() {
|
|
1059
|
+
for (const [, buffer] of this.batchBuffers) {
|
|
1060
|
+
if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
|
|
1061
|
+
if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
|
|
1062
|
+
}
|
|
1063
|
+
this.batchBuffers.clear();
|
|
1064
|
+
this.sessionStates.clear();
|
|
1065
|
+
if (instance3 === this) instance3 = void 0;
|
|
1066
|
+
}
|
|
1067
|
+
// --- Private ---
|
|
1068
|
+
resolvePort(mindName) {
|
|
1069
|
+
const [baseName, variantName] = mindName.split("@", 2);
|
|
1070
|
+
const entry = findMind(baseName);
|
|
1071
|
+
if (!entry) return null;
|
|
1072
|
+
if (variantName) {
|
|
1073
|
+
const variant = findVariant(baseName, variantName);
|
|
1074
|
+
if (!variant) return null;
|
|
1075
|
+
return { baseName, port: variant.port };
|
|
1076
|
+
}
|
|
1077
|
+
return { baseName, port: entry.port };
|
|
1078
|
+
}
|
|
1079
|
+
async postToMind(port, body) {
|
|
1080
|
+
const controller = new AbortController();
|
|
1081
|
+
const timeout = setTimeout(() => controller.abort(), 12e4);
|
|
1082
|
+
try {
|
|
1083
|
+
const res = await fetch(`http://127.0.0.1:${port}/message`, {
|
|
1084
|
+
method: "POST",
|
|
1085
|
+
headers: { "Content-Type": "application/json" },
|
|
1086
|
+
body,
|
|
1087
|
+
signal: controller.signal
|
|
1088
|
+
});
|
|
1089
|
+
if (!res.ok) {
|
|
1090
|
+
const text = await res.text().catch(() => "");
|
|
1091
|
+
dlog2.warn(`mind responded ${res.status}: ${text}`);
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
await res.text().catch(() => {
|
|
1095
|
+
});
|
|
1096
|
+
return true;
|
|
1097
|
+
} finally {
|
|
1098
|
+
clearTimeout(timeout);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
async deliverToMind(mindName, session, payload, sessionConfig) {
|
|
1102
|
+
const resolved = this.resolvePort(mindName);
|
|
1103
|
+
if (!resolved) {
|
|
1104
|
+
dlog2.warn(`cannot deliver to ${mindName}: mind not found`);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const { baseName, port } = resolved;
|
|
1108
|
+
const senders = /* @__PURE__ */ new Set();
|
|
1109
|
+
if (payload.sender) senders.add(payload.sender);
|
|
1110
|
+
const channels = /* @__PURE__ */ new Set();
|
|
1111
|
+
if (payload.channel) channels.add(payload.channel);
|
|
1112
|
+
this.incrementActive(baseName, session, senders, channels);
|
|
1113
|
+
const typingMap = getTypingMap();
|
|
1114
|
+
if (payload.channel) {
|
|
1115
|
+
typingMap.set(payload.channel, baseName, { persistent: true });
|
|
1116
|
+
}
|
|
1117
|
+
if (payload.conversationId) {
|
|
1118
|
+
typingMap.set(`volute:${payload.conversationId}`, baseName, { persistent: true });
|
|
1119
|
+
}
|
|
1120
|
+
const body = JSON.stringify({
|
|
1121
|
+
...payload,
|
|
1122
|
+
session,
|
|
1123
|
+
interrupt: sessionConfig.interrupt,
|
|
1124
|
+
instructions: sessionConfig.instructions
|
|
1125
|
+
});
|
|
1126
|
+
try {
|
|
1127
|
+
const ok = await this.postToMind(port, body);
|
|
1128
|
+
if (!ok) {
|
|
1129
|
+
this.decrementActive(baseName, session);
|
|
1130
|
+
publishTypingForChannels(typingMap.deleteSender(baseName), typingMap);
|
|
1131
|
+
}
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
dlog2.warn(`failed to deliver to ${mindName}`, logger_default.errorData(err));
|
|
1134
|
+
this.decrementActive(baseName, session);
|
|
1135
|
+
publishTypingForChannels(typingMap.deleteSender(baseName), typingMap);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
async deliverBatchToMind(mindName, session, messages, sessionConfig, interruptOverride) {
|
|
1139
|
+
const resolved = this.resolvePort(mindName);
|
|
1140
|
+
if (!resolved) {
|
|
1141
|
+
dlog2.warn(`cannot deliver batch to ${mindName}: mind not found`);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const { baseName, port } = resolved;
|
|
1145
|
+
const channels = {};
|
|
1146
|
+
for (const msg of messages) {
|
|
1147
|
+
const ch = msg.channel ?? "unknown";
|
|
1148
|
+
if (!channels[ch]) channels[ch] = [];
|
|
1149
|
+
channels[ch].push(msg.payload);
|
|
1150
|
+
}
|
|
1151
|
+
const senders = /* @__PURE__ */ new Set();
|
|
1152
|
+
const channelSet = /* @__PURE__ */ new Set();
|
|
1153
|
+
for (const msg of messages) {
|
|
1154
|
+
if (msg.sender) senders.add(msg.sender);
|
|
1155
|
+
if (msg.channel) channelSet.add(msg.channel);
|
|
1156
|
+
}
|
|
1157
|
+
this.incrementActive(baseName, session, senders, channelSet);
|
|
1158
|
+
const typingMap = getTypingMap();
|
|
1159
|
+
for (const ch of Object.keys(channels)) {
|
|
1160
|
+
if (ch !== "unknown") typingMap.set(ch, baseName, { persistent: true });
|
|
1161
|
+
}
|
|
1162
|
+
const seenConvIds = /* @__PURE__ */ new Set();
|
|
1163
|
+
for (const msg of messages) {
|
|
1164
|
+
if (msg.payload.conversationId && !seenConvIds.has(msg.payload.conversationId)) {
|
|
1165
|
+
seenConvIds.add(msg.payload.conversationId);
|
|
1166
|
+
typingMap.set(`volute:${msg.payload.conversationId}`, baseName, { persistent: true });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const body = JSON.stringify({
|
|
1170
|
+
session,
|
|
1171
|
+
batch: { channels },
|
|
1172
|
+
interrupt: interruptOverride ?? sessionConfig.interrupt,
|
|
1173
|
+
instructions: sessionConfig.instructions
|
|
1174
|
+
});
|
|
1175
|
+
try {
|
|
1176
|
+
const ok = await this.postToMind(port, body);
|
|
1177
|
+
if (!ok) {
|
|
1178
|
+
this.decrementActive(baseName, session);
|
|
1179
|
+
publishTypingForChannels(typingMap.deleteSender(baseName), typingMap);
|
|
1180
|
+
} else {
|
|
1181
|
+
try {
|
|
1182
|
+
const db = await getDb();
|
|
1183
|
+
await db.delete(deliveryQueue).where(
|
|
1184
|
+
and(
|
|
1185
|
+
eq(deliveryQueue.mind, baseName),
|
|
1186
|
+
eq(deliveryQueue.session, session),
|
|
1187
|
+
eq(deliveryQueue.status, "pending")
|
|
1188
|
+
)
|
|
1189
|
+
);
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
dlog2.warn(
|
|
1192
|
+
`failed to clean delivery queue for ${baseName}/${session}`,
|
|
1193
|
+
logger_default.errorData(err)
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
dlog2.warn(`failed to deliver batch to ${mindName}`, logger_default.errorData(err));
|
|
1199
|
+
this.decrementActive(baseName, session);
|
|
1200
|
+
publishTypingForChannels(typingMap.deleteSender(baseName), typingMap);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
enqueueBatch(mindName, session, payload, sessionConfig) {
|
|
1204
|
+
const delivery = sessionConfig.delivery;
|
|
1205
|
+
if (delivery.triggers?.length) {
|
|
1206
|
+
const text = extractTextContent(payload.content);
|
|
1207
|
+
const lower = text.toLowerCase();
|
|
1208
|
+
if (delivery.triggers.some((t) => lower.includes(t.toLowerCase()))) {
|
|
1209
|
+
this.flushBatch(mindName, session, [
|
|
1210
|
+
{
|
|
1211
|
+
payload,
|
|
1212
|
+
channel: payload.channel,
|
|
1213
|
+
sender: payload.sender ?? null,
|
|
1214
|
+
createdAt: Date.now()
|
|
1215
|
+
}
|
|
1216
|
+
]);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const [baseName] = mindName.split("@", 2);
|
|
1221
|
+
const state = this.sessionStates.get(baseName)?.get(session);
|
|
1222
|
+
if (state && state.activeCount > 0 && payload.sender && !state.lastDeliverySenders.has(payload.sender) && payload.channel && state.lastDeliveryChannels.has(payload.channel) && Date.now() - state.lastDeliveredAt < delivery.maxWait * 1e3 && Date.now() - state.lastInterruptAt > delivery.debounce * 1e3) {
|
|
1223
|
+
state.lastInterruptAt = Date.now();
|
|
1224
|
+
this.persistToQueue(mindName, session, payload).catch((err) => {
|
|
1225
|
+
dlog2.warn(`failed to persist batch message for ${mindName}/${session}`, logger_default.errorData(err));
|
|
1226
|
+
});
|
|
1227
|
+
this.flushBatch(
|
|
1228
|
+
mindName,
|
|
1229
|
+
session,
|
|
1230
|
+
[{ payload, channel: payload.channel, sender: payload.sender, createdAt: Date.now() }],
|
|
1231
|
+
true
|
|
1232
|
+
);
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
this.persistToQueue(mindName, session, payload).catch((err) => {
|
|
1236
|
+
dlog2.warn(`failed to persist batch message for ${mindName}/${session}`, logger_default.errorData(err));
|
|
1237
|
+
});
|
|
1238
|
+
this.addToBatchBuffer(mindName, session, payload, sessionConfig);
|
|
1239
|
+
}
|
|
1240
|
+
addToBatchBuffer(mindName, session, payload, sessionConfig) {
|
|
1241
|
+
const delivery = sessionConfig.delivery;
|
|
1242
|
+
const bufferKey = `${mindName}:${session}`;
|
|
1243
|
+
let buffer = this.batchBuffers.get(bufferKey);
|
|
1244
|
+
if (!buffer) {
|
|
1245
|
+
buffer = {
|
|
1246
|
+
messages: [],
|
|
1247
|
+
debounceTimer: null,
|
|
1248
|
+
maxWaitTimer: null,
|
|
1249
|
+
delivery
|
|
1250
|
+
};
|
|
1251
|
+
this.batchBuffers.set(bufferKey, buffer);
|
|
1252
|
+
}
|
|
1253
|
+
buffer.messages.push({
|
|
1254
|
+
payload,
|
|
1255
|
+
channel: payload.channel,
|
|
1256
|
+
sender: payload.sender ?? null,
|
|
1257
|
+
createdAt: Date.now()
|
|
1258
|
+
});
|
|
1259
|
+
if (buffer.messages.length >= MAX_BATCH_SIZE) {
|
|
1260
|
+
this.flushBatch(mindName, session);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
this.scheduleBatchTimers(mindName, session, bufferKey);
|
|
1264
|
+
}
|
|
1265
|
+
scheduleBatchTimers(mindName, session, bufferKey) {
|
|
1266
|
+
const buffer = this.batchBuffers.get(bufferKey);
|
|
1267
|
+
if (!buffer) return;
|
|
1268
|
+
if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
|
|
1269
|
+
buffer.debounceTimer = setTimeout(() => {
|
|
1270
|
+
if (!this.isSessionBusy(mindName, session)) {
|
|
1271
|
+
this.flushBatch(mindName, session);
|
|
1272
|
+
}
|
|
1273
|
+
}, buffer.delivery.debounce * 1e3);
|
|
1274
|
+
buffer.debounceTimer.unref();
|
|
1275
|
+
if (!buffer.maxWaitTimer) {
|
|
1276
|
+
buffer.maxWaitTimer = setTimeout(() => {
|
|
1277
|
+
this.flushBatch(mindName, session);
|
|
1278
|
+
}, buffer.delivery.maxWait * 1e3);
|
|
1279
|
+
buffer.maxWaitTimer.unref();
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
flushBatch(mindName, session, extra, interruptOverride) {
|
|
1283
|
+
const bufferKey = `${mindName}:${session}`;
|
|
1284
|
+
const buffer = this.batchBuffers.get(bufferKey);
|
|
1285
|
+
const messages = [];
|
|
1286
|
+
if (buffer) {
|
|
1287
|
+
if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
|
|
1288
|
+
if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
|
|
1289
|
+
buffer.debounceTimer = null;
|
|
1290
|
+
buffer.maxWaitTimer = null;
|
|
1291
|
+
messages.push(...buffer.messages.splice(0));
|
|
1292
|
+
this.batchBuffers.delete(bufferKey);
|
|
1293
|
+
}
|
|
1294
|
+
if (extra) messages.push(...extra);
|
|
1295
|
+
if (messages.length === 0) return;
|
|
1296
|
+
const [baseName] = mindName.split("@", 2);
|
|
1297
|
+
const config = getRoutingConfig(baseName);
|
|
1298
|
+
const sessionConfig = resolveDeliveryMode(config, session);
|
|
1299
|
+
dlog2.info(
|
|
1300
|
+
`flushing batch for ${mindName}/${session}: ${messages.length} messages${interruptOverride ? " (new-speaker interrupt)" : ""}`
|
|
1301
|
+
);
|
|
1302
|
+
this.deliverBatchToMind(mindName, session, messages, sessionConfig, interruptOverride).catch(
|
|
1303
|
+
(err) => {
|
|
1304
|
+
dlog2.warn(`failed to flush batch for ${mindName}/${session}`, logger_default.errorData(err));
|
|
1305
|
+
}
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
async gateMessage(mindName, session, payload) {
|
|
1309
|
+
const [baseName] = mindName.split("@", 2);
|
|
1310
|
+
await this.persistToQueue(baseName, session, payload, "gated");
|
|
1311
|
+
try {
|
|
1312
|
+
const db = await getDb();
|
|
1313
|
+
const count = await db.select({ count: sql`count(*)` }).from(deliveryQueue).where(
|
|
1314
|
+
and(
|
|
1315
|
+
eq(deliveryQueue.mind, baseName),
|
|
1316
|
+
eq(deliveryQueue.channel, payload.channel),
|
|
1317
|
+
eq(deliveryQueue.status, "gated")
|
|
1318
|
+
)
|
|
1319
|
+
);
|
|
1320
|
+
if ((count[0]?.count ?? 0) <= 1) {
|
|
1321
|
+
await this.sendInviteNotification(mindName, payload);
|
|
1322
|
+
}
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
dlog2.warn(`failed to check gated count for ${baseName}`, logger_default.errorData(err));
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
async sendInviteNotification(mindName, payload) {
|
|
1328
|
+
const text = extractTextContent(payload.content);
|
|
1329
|
+
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
|
1330
|
+
const channel = payload.channel ?? "unknown";
|
|
1331
|
+
const notification = [
|
|
1332
|
+
`[New channel: ${channel}]`,
|
|
1333
|
+
`Sender: ${payload.sender ?? "unknown"}`,
|
|
1334
|
+
payload.platform ? `Platform: ${payload.platform}` : null,
|
|
1335
|
+
payload.participantCount ? `Participants: ${payload.participantCount}` : null,
|
|
1336
|
+
"",
|
|
1337
|
+
`Preview: ${preview}`,
|
|
1338
|
+
"",
|
|
1339
|
+
`To accept this channel, add a routing rule for "${channel}" to your routes.json.`,
|
|
1340
|
+
`Messages are being held until a route is configured.`
|
|
1341
|
+
].filter((line) => line !== null).join("\n");
|
|
1342
|
+
const invitePayload = {
|
|
1343
|
+
channel: "system:delivery",
|
|
1344
|
+
sender: "system",
|
|
1345
|
+
content: [{ type: "text", text: notification }]
|
|
1346
|
+
};
|
|
1347
|
+
const config = getRoutingConfig(mindName.split("@", 2)[0]);
|
|
1348
|
+
const sessionConfig = resolveDeliveryMode(config, "main");
|
|
1349
|
+
await this.deliverToMind(mindName, "main", invitePayload, {
|
|
1350
|
+
...sessionConfig,
|
|
1351
|
+
interrupt: true
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
async persistToQueue(mindName, session, payload, status = "pending") {
|
|
1355
|
+
try {
|
|
1356
|
+
const db = await getDb();
|
|
1357
|
+
await db.insert(deliveryQueue).values({
|
|
1358
|
+
mind: mindName,
|
|
1359
|
+
session,
|
|
1360
|
+
channel: payload.channel ?? null,
|
|
1361
|
+
sender: payload.sender ?? null,
|
|
1362
|
+
status,
|
|
1363
|
+
payload: JSON.stringify(payload)
|
|
1364
|
+
});
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
dlog2.warn(
|
|
1367
|
+
`failed to persist to delivery queue for ${mindName}/${session}`,
|
|
1368
|
+
logger_default.errorData(err)
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
incrementActive(mind, session, senders, channels) {
|
|
1373
|
+
let mindSessions = this.sessionStates.get(mind);
|
|
1374
|
+
if (!mindSessions) {
|
|
1375
|
+
mindSessions = /* @__PURE__ */ new Map();
|
|
1376
|
+
this.sessionStates.set(mind, mindSessions);
|
|
1377
|
+
}
|
|
1378
|
+
const state = mindSessions.get(session) ?? {
|
|
1379
|
+
activeCount: 0,
|
|
1380
|
+
lastDeliveredAt: 0,
|
|
1381
|
+
lastDeliverySenders: /* @__PURE__ */ new Set(),
|
|
1382
|
+
lastDeliveryChannels: /* @__PURE__ */ new Set(),
|
|
1383
|
+
lastInterruptAt: 0
|
|
1384
|
+
};
|
|
1385
|
+
state.activeCount++;
|
|
1386
|
+
state.lastDeliveredAt = Date.now();
|
|
1387
|
+
if (senders) state.lastDeliverySenders = senders;
|
|
1388
|
+
if (channels) state.lastDeliveryChannels = channels;
|
|
1389
|
+
mindSessions.set(session, state);
|
|
1390
|
+
}
|
|
1391
|
+
decrementActive(mind, session) {
|
|
1392
|
+
const mindSessions = this.sessionStates.get(mind);
|
|
1393
|
+
if (!mindSessions) return;
|
|
1394
|
+
const state = mindSessions.get(session);
|
|
1395
|
+
if (!state) return;
|
|
1396
|
+
state.activeCount = Math.max(0, state.activeCount - 1);
|
|
1397
|
+
if (state.activeCount === 0) {
|
|
1398
|
+
const bufferKey = `${mind}:${session}`;
|
|
1399
|
+
const buffer = this.batchBuffers.get(bufferKey);
|
|
1400
|
+
if (buffer && buffer.messages.length > 0) {
|
|
1401
|
+
this.scheduleBatchTimers(mind, session, bufferKey);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
var instance3;
|
|
1407
|
+
function initDeliveryManager() {
|
|
1408
|
+
if (instance3) throw new Error("DeliveryManager already initialized");
|
|
1409
|
+
instance3 = new DeliveryManager();
|
|
1410
|
+
return instance3;
|
|
1411
|
+
}
|
|
1412
|
+
function getDeliveryManager() {
|
|
1413
|
+
if (!instance3) {
|
|
1414
|
+
throw new Error("DeliveryManager not initialized \u2014 call initDeliveryManager() first");
|
|
1415
|
+
}
|
|
1416
|
+
return instance3;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// src/lib/delivery/message-delivery.ts
|
|
1420
|
+
var dlog3 = logger_default.child("delivery");
|
|
1421
|
+
async function deliverMessage(mindName, payload) {
|
|
1422
|
+
try {
|
|
1423
|
+
const [baseName] = mindName.split("@", 2);
|
|
1424
|
+
const entry = findMind(baseName);
|
|
1425
|
+
if (!entry) {
|
|
1426
|
+
dlog3.warn(`cannot deliver to ${mindName}: mind not found`);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
const textContent = extractTextContent(payload.content);
|
|
1430
|
+
try {
|
|
1431
|
+
const db = await getDb();
|
|
1432
|
+
await db.insert(mindHistory).values({
|
|
1433
|
+
mind: baseName,
|
|
1434
|
+
type: "inbound",
|
|
1435
|
+
channel: payload.channel,
|
|
1436
|
+
sender: payload.sender ?? null,
|
|
1437
|
+
content: textContent
|
|
1438
|
+
});
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
dlog3.warn(`failed to persist message for ${baseName}`, logger_default.errorData(err));
|
|
1441
|
+
}
|
|
1442
|
+
const sleepManager = getSleepManagerIfReady();
|
|
1443
|
+
if (sleepManager?.isSleeping(baseName)) {
|
|
1444
|
+
if (sleepManager.checkWakeTrigger(baseName, payload)) {
|
|
1445
|
+
await sleepManager.queueSleepMessage(baseName, payload);
|
|
1446
|
+
sleepManager.initiateWake(baseName, { trigger: { channel: payload.channel } }).catch((err) => dlog3.warn(`failed to trigger-wake ${baseName}`, logger_default.errorData(err)));
|
|
1447
|
+
} else {
|
|
1448
|
+
await sleepManager.queueSleepMessage(baseName, payload);
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const manager = getDeliveryManager();
|
|
1453
|
+
await manager.routeAndDeliver(mindName, payload);
|
|
1454
|
+
} catch (err) {
|
|
1455
|
+
dlog3.warn(`unexpected error delivering to ${mindName}`, logger_default.errorData(err));
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// src/lib/daemon/mail-poller.ts
|
|
1460
|
+
var mlog = logger_default.child("mail");
|
|
1461
|
+
function formatEmailContent(email) {
|
|
1462
|
+
if (email.body) {
|
|
1463
|
+
return email.subject ? `Subject: ${email.subject}
|
|
1464
|
+
|
|
1465
|
+
${email.body}` : email.body;
|
|
1466
|
+
}
|
|
1467
|
+
if (email.html) {
|
|
1468
|
+
return email.subject ? `Subject: ${email.subject}
|
|
1469
|
+
|
|
1470
|
+
[HTML email \u2014 plain text not available]` : "[HTML email \u2014 plain text not available]";
|
|
1471
|
+
}
|
|
1472
|
+
return email.subject ? `Subject: ${email.subject}` : "[Empty email]";
|
|
1473
|
+
}
|
|
1474
|
+
var PING_INTERVAL_MS = 3e4;
|
|
1475
|
+
var INITIAL_RECONNECT_MS = 1e3;
|
|
1476
|
+
var MAX_RECONNECT_MS = 6e4;
|
|
1477
|
+
var MailPoller = class {
|
|
1478
|
+
ws = null;
|
|
1479
|
+
running = false;
|
|
1480
|
+
pingTimer = null;
|
|
1481
|
+
reconnectTimer = null;
|
|
1482
|
+
reconnectDelay = INITIAL_RECONNECT_MS;
|
|
1483
|
+
reconnectAttempts = 0;
|
|
1484
|
+
disconnectedAt = null;
|
|
1485
|
+
config = null;
|
|
1486
|
+
start() {
|
|
1487
|
+
if (this.running) {
|
|
1488
|
+
mlog.warn("already running \u2014 ignoring duplicate start");
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
this.config = readSystemsConfig();
|
|
1492
|
+
if (!this.config) {
|
|
1493
|
+
mlog.info("no systems config \u2014 mail disabled");
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
this.running = true;
|
|
1497
|
+
this.connect();
|
|
1498
|
+
}
|
|
1499
|
+
stop() {
|
|
1500
|
+
this.running = false;
|
|
1501
|
+
this.config = null;
|
|
1502
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
1503
|
+
this.pingTimer = null;
|
|
1504
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
1505
|
+
this.reconnectTimer = null;
|
|
1506
|
+
if (this.ws) {
|
|
1507
|
+
this.ws.close();
|
|
1508
|
+
this.ws = null;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
isRunning() {
|
|
1512
|
+
return this.running;
|
|
1513
|
+
}
|
|
1514
|
+
connect() {
|
|
1515
|
+
if (!this.running) return;
|
|
1516
|
+
this.config = readSystemsConfig();
|
|
1517
|
+
if (!this.config) {
|
|
1518
|
+
mlog.info("systems config removed \u2014 stopping");
|
|
1519
|
+
this.stop();
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const wsUrl = `${this.config.apiUrl.replace(/^http/, "ws")}/api/ws`;
|
|
1523
|
+
try {
|
|
1524
|
+
this.ws = new WebSocket(wsUrl, {
|
|
1525
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}` }
|
|
1526
|
+
});
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
mlog.warn("failed to create WebSocket", logger_default.errorData(err));
|
|
1529
|
+
this.scheduleReconnect();
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
this.ws.onopen = () => {
|
|
1533
|
+
if (this.reconnectAttempts > 0) {
|
|
1534
|
+
mlog.info(`reconnected after ${this.reconnectAttempts} attempts`);
|
|
1535
|
+
}
|
|
1536
|
+
mlog.info("connected");
|
|
1537
|
+
this.reconnectAttempts = 0;
|
|
1538
|
+
this.reconnectDelay = INITIAL_RECONNECT_MS;
|
|
1539
|
+
if (this.disconnectedAt) {
|
|
1540
|
+
this.catchUp(this.disconnectedAt);
|
|
1541
|
+
this.disconnectedAt = null;
|
|
1542
|
+
}
|
|
1543
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
1544
|
+
this.pingTimer = setInterval(() => {
|
|
1545
|
+
try {
|
|
1546
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
1547
|
+
this.ws.send("ping");
|
|
1548
|
+
}
|
|
1549
|
+
} catch (err) {
|
|
1550
|
+
mlog.warn("ping failed", logger_default.errorData(err));
|
|
1551
|
+
}
|
|
1552
|
+
}, PING_INTERVAL_MS);
|
|
1553
|
+
};
|
|
1554
|
+
this.ws.onmessage = (event) => {
|
|
1555
|
+
this.handleMessage(String(event.data));
|
|
1556
|
+
};
|
|
1557
|
+
this.ws.onclose = () => {
|
|
1558
|
+
mlog.warn("disconnected");
|
|
1559
|
+
if (!this.disconnectedAt) {
|
|
1560
|
+
this.disconnectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1561
|
+
}
|
|
1562
|
+
this.cleanup();
|
|
1563
|
+
this.scheduleReconnect();
|
|
1564
|
+
};
|
|
1565
|
+
this.ws.onerror = (err) => {
|
|
1566
|
+
mlog.warn("WebSocket error", logger_default.errorData(err));
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
cleanup() {
|
|
1570
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
1571
|
+
this.pingTimer = null;
|
|
1572
|
+
this.ws = null;
|
|
1573
|
+
}
|
|
1574
|
+
scheduleReconnect() {
|
|
1575
|
+
if (!this.running) return;
|
|
1576
|
+
this.reconnectAttempts++;
|
|
1577
|
+
if (this.reconnectAttempts % 10 === 0) {
|
|
1578
|
+
mlog.warn(
|
|
1579
|
+
`failed to connect ${this.reconnectAttempts} times \u2014 check systems config and network`
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
mlog.info(`reconnecting in ${this.reconnectDelay}ms (attempt ${this.reconnectAttempts})`);
|
|
1583
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1584
|
+
this.reconnectTimer = null;
|
|
1585
|
+
this.connect();
|
|
1586
|
+
}, this.reconnectDelay);
|
|
1587
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_MS);
|
|
1588
|
+
}
|
|
1589
|
+
/** Fetch emails that arrived while disconnected */
|
|
1590
|
+
catchUp(since) {
|
|
1591
|
+
if (!this.config) return;
|
|
1592
|
+
const url = `${this.config.apiUrl}/api/mail/system/poll?since=${encodeURIComponent(since)}`;
|
|
1593
|
+
fetch(url, {
|
|
1594
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}` }
|
|
1595
|
+
}).then(async (res) => {
|
|
1596
|
+
if (!res.ok) {
|
|
1597
|
+
mlog.warn(`catch-up poll failed: HTTP ${res.status}`);
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const data = await res.json();
|
|
1601
|
+
if (!Array.isArray(data.emails) || data.emails.length === 0) return;
|
|
1602
|
+
mlog.info(`catching up on ${data.emails.length} missed emails`);
|
|
1603
|
+
for (const email of data.emails) {
|
|
1604
|
+
await this.deliver(email.mind, email);
|
|
1605
|
+
}
|
|
1606
|
+
}).catch((err) => {
|
|
1607
|
+
mlog.warn("catch-up error", logger_default.errorData(err));
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
handleMessage(data) {
|
|
1611
|
+
if (data === "pong") return;
|
|
1612
|
+
let msg;
|
|
1613
|
+
try {
|
|
1614
|
+
msg = JSON.parse(data);
|
|
1615
|
+
} catch {
|
|
1616
|
+
mlog.warn(`received unparseable message: ${data.slice(0, 200)}`);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
if (msg.type !== "email") return;
|
|
1620
|
+
if (!msg.mind || !msg.email?.id) {
|
|
1621
|
+
mlog.warn(`received malformed email notification: ${data.slice(0, 500)}`);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
this.fetchAndDeliver(msg.mind, msg.email).catch((err) => {
|
|
1625
|
+
mlog.warn(`failed to process email for ${msg.mind}`, logger_default.errorData(err));
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
async fetchAndDeliver(mind, notification) {
|
|
1629
|
+
if (!this.config) {
|
|
1630
|
+
mlog.warn(`systems config missing \u2014 cannot fetch email ${notification.id} for ${mind}`);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
const url = `${this.config.apiUrl}/api/mail/emails/${encodeURIComponent(mind)}/${encodeURIComponent(notification.id)}`;
|
|
1634
|
+
const res = await fetch(url, {
|
|
1635
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}` }
|
|
1636
|
+
});
|
|
1637
|
+
if (!res.ok) {
|
|
1638
|
+
mlog.warn(`failed to fetch email ${notification.id}: HTTP ${res.status}`);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
const email = await res.json();
|
|
1642
|
+
await this.deliver(mind, { ...email, mind });
|
|
1643
|
+
}
|
|
1644
|
+
async deliver(mind, email) {
|
|
1645
|
+
const entry = findMind(mind);
|
|
1646
|
+
if (!entry || !entry.running) {
|
|
1647
|
+
mlog.warn(`skipping delivery to ${mind}: ${!entry ? "not found" : "not running"}`);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
const text = formatEmailContent(email);
|
|
1651
|
+
try {
|
|
1652
|
+
await deliverMessage(mind, {
|
|
1653
|
+
content: [{ type: "text", text }],
|
|
1654
|
+
channel: `mail:${email.from.address}`,
|
|
1655
|
+
sender: email.from.name || email.from.address,
|
|
1656
|
+
platform: "Email",
|
|
1657
|
+
isDM: true
|
|
1658
|
+
});
|
|
1659
|
+
mlog.info(`delivered email from ${email.from.address} to ${mind}`);
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
mlog.warn(`failed to deliver to ${mind}`, logger_default.errorData(err));
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
var instance4 = null;
|
|
1666
|
+
function initMailPoller() {
|
|
1667
|
+
if (instance4) throw new Error("MailPoller already initialized");
|
|
1668
|
+
instance4 = new MailPoller();
|
|
1669
|
+
return instance4;
|
|
1670
|
+
}
|
|
1671
|
+
function getMailPoller() {
|
|
1672
|
+
if (!instance4) throw new Error("MailPoller not initialized \u2014 call initMailPoller() first");
|
|
1673
|
+
return instance4;
|
|
1674
|
+
}
|
|
1675
|
+
async function ensureMailAddress(mindName) {
|
|
1676
|
+
const config = readSystemsConfig();
|
|
1677
|
+
if (!config) return;
|
|
1678
|
+
try {
|
|
1679
|
+
const res = await fetch(`${config.apiUrl}/api/mail/addresses/${encodeURIComponent(mindName)}`, {
|
|
1680
|
+
method: "PUT",
|
|
1681
|
+
headers: {
|
|
1682
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
1683
|
+
"Content-Type": "application/json"
|
|
1684
|
+
}
|
|
1685
|
+
});
|
|
1686
|
+
if (!res.ok) {
|
|
1687
|
+
mlog.warn(`failed to ensure address for ${mindName}: HTTP ${res.status}`);
|
|
1688
|
+
}
|
|
1689
|
+
await res.text().catch(() => {
|
|
1690
|
+
});
|
|
1691
|
+
} catch (err) {
|
|
1692
|
+
mlog.warn(`failed to ensure address for ${mindName}`, logger_default.errorData(err));
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// src/lib/daemon/scheduler.ts
|
|
1697
|
+
import { resolve as resolve5 } from "path";
|
|
1698
|
+
import { CronExpressionParser } from "cron-parser";
|
|
1699
|
+
var slog = logger_default.child("scheduler");
|
|
1700
|
+
var Scheduler = class {
|
|
1701
|
+
schedules = /* @__PURE__ */ new Map();
|
|
1702
|
+
interval = null;
|
|
1703
|
+
lastFired = /* @__PURE__ */ new Map();
|
|
1704
|
+
// "mind:scheduleId" → epoch minute
|
|
1705
|
+
get statePath() {
|
|
1706
|
+
return resolve5(voluteHome(), "scheduler-state.json");
|
|
1707
|
+
}
|
|
1708
|
+
start() {
|
|
1709
|
+
this.loadState();
|
|
1710
|
+
this.interval = setInterval(() => this.tick(), 6e4);
|
|
1711
|
+
}
|
|
1712
|
+
stop() {
|
|
1713
|
+
if (this.interval) clearInterval(this.interval);
|
|
1714
|
+
}
|
|
1715
|
+
loadState() {
|
|
1716
|
+
this.lastFired = loadJsonMap(this.statePath);
|
|
1717
|
+
}
|
|
1718
|
+
saveState() {
|
|
1719
|
+
saveJsonMap(this.statePath, this.lastFired);
|
|
1720
|
+
}
|
|
1721
|
+
clearState() {
|
|
1722
|
+
clearJsonMap(this.statePath, this.lastFired);
|
|
1723
|
+
}
|
|
1724
|
+
loadSchedules(mindName) {
|
|
1725
|
+
const dir = mindDir(mindName);
|
|
1726
|
+
const config = readVoluteConfig(dir);
|
|
1727
|
+
if (!config) return;
|
|
1728
|
+
const schedules = config.schedules ?? [];
|
|
1729
|
+
if (schedules.length > 0) {
|
|
1730
|
+
this.schedules.set(mindName, schedules);
|
|
1731
|
+
} else {
|
|
1732
|
+
this.schedules.delete(mindName);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
unloadSchedules(mindName) {
|
|
1736
|
+
this.schedules.delete(mindName);
|
|
1737
|
+
}
|
|
1738
|
+
tick() {
|
|
1739
|
+
const now = /* @__PURE__ */ new Date();
|
|
1740
|
+
const epochMinute = Math.floor(now.getTime() / 6e4);
|
|
1741
|
+
const cronCache = /* @__PURE__ */ new Map();
|
|
1742
|
+
let anyFired = false;
|
|
1743
|
+
for (const [mind, schedules] of this.schedules) {
|
|
1744
|
+
for (const schedule of schedules) {
|
|
1745
|
+
if (!schedule.enabled) continue;
|
|
1746
|
+
if (this.shouldFire(schedule, epochMinute, mind, cronCache)) {
|
|
1747
|
+
anyFired = true;
|
|
1748
|
+
this.fire(mind, schedule);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
if (anyFired) this.saveState();
|
|
1753
|
+
}
|
|
1754
|
+
shouldFire(schedule, epochMinute, mind, cronCache) {
|
|
1755
|
+
const key = `${mind}:${schedule.id}`;
|
|
1756
|
+
if (this.lastFired.get(key) === epochMinute) return false;
|
|
1757
|
+
let prevMinute = cronCache.get(schedule.cron);
|
|
1758
|
+
if (prevMinute === void 0) {
|
|
1759
|
+
try {
|
|
1760
|
+
const interval = CronExpressionParser.parse(schedule.cron);
|
|
1761
|
+
const prev = interval.prev().toDate();
|
|
1762
|
+
prevMinute = Math.floor(prev.getTime() / 6e4);
|
|
1763
|
+
cronCache.set(schedule.cron, prevMinute);
|
|
1764
|
+
} catch (err) {
|
|
1765
|
+
slog.warn(`invalid cron "${schedule.cron}" for ${mind}:${schedule.id}`, logger_default.errorData(err));
|
|
1766
|
+
return false;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
if (prevMinute === epochMinute) {
|
|
1770
|
+
this.lastFired.set(key, epochMinute);
|
|
1771
|
+
return true;
|
|
1772
|
+
}
|
|
1773
|
+
return false;
|
|
1774
|
+
}
|
|
1775
|
+
async fire(mindName, schedule) {
|
|
1776
|
+
try {
|
|
1777
|
+
let text;
|
|
1778
|
+
if (schedule.script) {
|
|
1779
|
+
const homeDir = resolve5(mindDir(mindName), "home");
|
|
1780
|
+
try {
|
|
1781
|
+
const output = await this.runScript(schedule.script, homeDir, mindName);
|
|
1782
|
+
if (!output.trim()) {
|
|
1783
|
+
slog.info(`fired script "${schedule.id}" for ${mindName} (no output)`);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
text = output;
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
const stderr = err.stderr ?? "";
|
|
1789
|
+
text = `[script error] ${err.message}${stderr ? `
|
|
1790
|
+
${stderr}` : ""}`;
|
|
1791
|
+
slog.warn(`script "${schedule.id}" failed for ${mindName}`, logger_default.errorData(err));
|
|
1792
|
+
}
|
|
1793
|
+
} else if (schedule.message) {
|
|
1794
|
+
text = schedule.message;
|
|
1795
|
+
} else {
|
|
1796
|
+
slog.warn(`schedule "${schedule.id}" for ${mindName} has no message or script`);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
await this.deliver(mindName, {
|
|
1800
|
+
content: [{ type: "text", text }],
|
|
1801
|
+
channel: "system:scheduler",
|
|
1802
|
+
sender: schedule.id
|
|
1803
|
+
});
|
|
1804
|
+
slog.info(`fired "${schedule.id}" for ${mindName}`);
|
|
1805
|
+
} catch (err) {
|
|
1806
|
+
slog.warn(`failed to fire "${schedule.id}" for ${mindName}`, logger_default.errorData(err));
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
runScript(script, cwd, mindName) {
|
|
1810
|
+
return exec("bash", ["-c", script], { cwd, mindName });
|
|
1811
|
+
}
|
|
1812
|
+
deliver(mindName, payload) {
|
|
1813
|
+
return deliverMessage(mindName, payload);
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
1816
|
+
var instance5 = null;
|
|
1817
|
+
function initScheduler() {
|
|
1818
|
+
if (instance5) throw new Error("Scheduler already initialized");
|
|
1819
|
+
instance5 = new Scheduler();
|
|
1820
|
+
return instance5;
|
|
1821
|
+
}
|
|
1822
|
+
function getScheduler() {
|
|
1823
|
+
if (!instance5) throw new Error("Scheduler not initialized \u2014 call initScheduler() first");
|
|
1824
|
+
return instance5;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// src/lib/daemon/token-budget.ts
|
|
1828
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
1829
|
+
import { resolve as resolve6 } from "path";
|
|
1830
|
+
var tlog = logger_default.child("token-budget");
|
|
1831
|
+
var DEFAULT_BUDGET_PERIOD_MINUTES = 60;
|
|
1832
|
+
var MAX_QUEUE_SIZE = 100;
|
|
1833
|
+
var TokenBudget = class {
|
|
1834
|
+
budgets = /* @__PURE__ */ new Map();
|
|
1835
|
+
interval = null;
|
|
1836
|
+
dirty = /* @__PURE__ */ new Set();
|
|
1837
|
+
start() {
|
|
1838
|
+
this.interval = setInterval(() => this.tick(), 6e4);
|
|
1839
|
+
}
|
|
1840
|
+
stop() {
|
|
1841
|
+
this.flush();
|
|
1842
|
+
if (this.interval) clearInterval(this.interval);
|
|
1843
|
+
this.interval = null;
|
|
1844
|
+
}
|
|
1845
|
+
setBudget(mind, tokenLimit, periodMinutes) {
|
|
1846
|
+
if (tokenLimit <= 0) return;
|
|
1847
|
+
const existing = this.budgets.get(mind);
|
|
1848
|
+
if (existing) {
|
|
1849
|
+
existing.tokenLimit = tokenLimit;
|
|
1850
|
+
existing.periodMinutes = periodMinutes;
|
|
1851
|
+
} else {
|
|
1852
|
+
const persisted = this.loadBudgetState(mind);
|
|
1853
|
+
if (persisted) {
|
|
1854
|
+
persisted.tokenLimit = tokenLimit;
|
|
1855
|
+
persisted.periodMinutes = periodMinutes;
|
|
1856
|
+
this.budgets.set(mind, persisted);
|
|
1857
|
+
} else {
|
|
1858
|
+
this.budgets.set(mind, {
|
|
1859
|
+
tokensUsed: 0,
|
|
1860
|
+
periodStart: Date.now(),
|
|
1861
|
+
periodMinutes,
|
|
1862
|
+
tokenLimit,
|
|
1863
|
+
queue: [],
|
|
1864
|
+
warningInjected: false
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
removeBudget(mind) {
|
|
1870
|
+
this.budgets.delete(mind);
|
|
1871
|
+
}
|
|
1872
|
+
recordUsage(mind, inputTokens, outputTokens) {
|
|
1873
|
+
const state = this.budgets.get(mind);
|
|
1874
|
+
if (!state) return;
|
|
1875
|
+
state.tokensUsed += inputTokens + outputTokens;
|
|
1876
|
+
this.dirty.add(mind);
|
|
1877
|
+
}
|
|
1878
|
+
/** Returns current budget status. Does not mutate state — call acknowledgeWarning() after delivering a warning. */
|
|
1879
|
+
checkBudget(mind) {
|
|
1880
|
+
const state = this.budgets.get(mind);
|
|
1881
|
+
if (!state) return "ok";
|
|
1882
|
+
const pct = state.tokensUsed / state.tokenLimit;
|
|
1883
|
+
if (pct >= 1) return "exceeded";
|
|
1884
|
+
if (pct >= 0.8 && !state.warningInjected) return "warning";
|
|
1885
|
+
return "ok";
|
|
1886
|
+
}
|
|
1887
|
+
/** Mark warning as delivered for this period. Call after successfully injecting the warning. */
|
|
1888
|
+
acknowledgeWarning(mind) {
|
|
1889
|
+
const state = this.budgets.get(mind);
|
|
1890
|
+
if (state) state.warningInjected = true;
|
|
1891
|
+
}
|
|
1892
|
+
enqueue(mind, message) {
|
|
1893
|
+
const state = this.budgets.get(mind);
|
|
1894
|
+
if (!state) return;
|
|
1895
|
+
if (state.queue.length >= MAX_QUEUE_SIZE) {
|
|
1896
|
+
state.queue.shift();
|
|
1897
|
+
}
|
|
1898
|
+
state.queue.push(message);
|
|
1899
|
+
}
|
|
1900
|
+
drain(mind) {
|
|
1901
|
+
const state = this.budgets.get(mind);
|
|
1902
|
+
if (!state) return [];
|
|
1903
|
+
const messages = state.queue;
|
|
1904
|
+
state.queue = [];
|
|
1905
|
+
return messages;
|
|
1906
|
+
}
|
|
1907
|
+
getUsage(mind) {
|
|
1908
|
+
const state = this.budgets.get(mind);
|
|
1909
|
+
if (!state) return null;
|
|
1910
|
+
return {
|
|
1911
|
+
tokensUsed: state.tokensUsed,
|
|
1912
|
+
tokenLimit: state.tokenLimit,
|
|
1913
|
+
periodMinutes: state.periodMinutes,
|
|
1914
|
+
periodStart: state.periodStart,
|
|
1915
|
+
queueLength: state.queue.length,
|
|
1916
|
+
percentUsed: Math.round(state.tokensUsed / state.tokenLimit * 100)
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
tick() {
|
|
1920
|
+
const now = Date.now();
|
|
1921
|
+
for (const [mind, state] of this.budgets) {
|
|
1922
|
+
const elapsed = now - state.periodStart;
|
|
1923
|
+
if (elapsed >= state.periodMinutes * 6e4) {
|
|
1924
|
+
state.tokensUsed = 0;
|
|
1925
|
+
state.periodStart = now;
|
|
1926
|
+
state.warningInjected = false;
|
|
1927
|
+
this.dirty.add(mind);
|
|
1928
|
+
const queued = this.drain(mind);
|
|
1929
|
+
if (queued.length > 0) {
|
|
1930
|
+
this.replay(mind, queued).catch((err) => {
|
|
1931
|
+
tlog.warn(`replay error for ${mind}`, logger_default.errorData(err));
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
this.flush();
|
|
1937
|
+
}
|
|
1938
|
+
/** Flush all dirty budget states to disk. */
|
|
1939
|
+
flush() {
|
|
1940
|
+
for (const mind of this.dirty) {
|
|
1941
|
+
const state = this.budgets.get(mind);
|
|
1942
|
+
if (state) this.saveBudgetState(mind, state);
|
|
1943
|
+
}
|
|
1944
|
+
this.dirty.clear();
|
|
1945
|
+
}
|
|
1946
|
+
budgetStatePath(mind) {
|
|
1947
|
+
return resolve6(stateDir(mind), "budget.json");
|
|
1948
|
+
}
|
|
1949
|
+
saveBudgetState(mind, state) {
|
|
1950
|
+
try {
|
|
1951
|
+
const dir = stateDir(mind);
|
|
1952
|
+
mkdirSync2(dir, { recursive: true });
|
|
1953
|
+
const data = {
|
|
1954
|
+
periodStart: state.periodStart,
|
|
1955
|
+
tokensUsed: state.tokensUsed,
|
|
1956
|
+
warningInjected: state.warningInjected,
|
|
1957
|
+
queue: state.queue
|
|
1958
|
+
};
|
|
1959
|
+
writeFileSync2(this.budgetStatePath(mind), `${JSON.stringify(data)}
|
|
1960
|
+
`);
|
|
1961
|
+
} catch (err) {
|
|
1962
|
+
tlog.warn(`failed to save budget state for ${mind}`, logger_default.errorData(err));
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
loadBudgetState(mind) {
|
|
1966
|
+
try {
|
|
1967
|
+
const path = this.budgetStatePath(mind);
|
|
1968
|
+
if (!existsSync4(path)) return null;
|
|
1969
|
+
const data = JSON.parse(readFileSync4(path, "utf-8"));
|
|
1970
|
+
if (typeof data.periodStart !== "number" || typeof data.tokensUsed !== "number") return null;
|
|
1971
|
+
return {
|
|
1972
|
+
periodStart: data.periodStart,
|
|
1973
|
+
tokensUsed: data.tokensUsed,
|
|
1974
|
+
warningInjected: data.warningInjected ?? false,
|
|
1975
|
+
queue: Array.isArray(data.queue) ? data.queue : [],
|
|
1976
|
+
periodMinutes: 0,
|
|
1977
|
+
// will be overwritten by caller
|
|
1978
|
+
tokenLimit: 0
|
|
1979
|
+
// will be overwritten by caller
|
|
1980
|
+
};
|
|
1981
|
+
} catch (err) {
|
|
1982
|
+
tlog.warn(`failed to load budget state for ${mind}`, logger_default.errorData(err));
|
|
1983
|
+
return null;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
async replay(mindName, messages) {
|
|
1987
|
+
const summary = messages.map((m) => {
|
|
1988
|
+
const from = m.sender ? `[${m.sender}]` : "";
|
|
1989
|
+
const ch = m.channel ? `(${m.channel})` : "";
|
|
1990
|
+
return `${from}${ch} ${m.textContent}`;
|
|
1991
|
+
}).join("\n");
|
|
1992
|
+
try {
|
|
1993
|
+
await deliverMessage(mindName, {
|
|
1994
|
+
content: [
|
|
1995
|
+
{
|
|
1996
|
+
type: "text",
|
|
1997
|
+
text: `[Budget replay] ${messages.length} queued message(s) from the previous budget period:
|
|
1998
|
+
|
|
1999
|
+
${summary}`
|
|
2000
|
+
}
|
|
2001
|
+
],
|
|
2002
|
+
channel: "system:budget-replay",
|
|
2003
|
+
sender: "system"
|
|
2004
|
+
});
|
|
2005
|
+
tlog.info(`replayed ${messages.length} queued message(s) for ${mindName}`);
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
tlog.warn(`failed to replay for ${mindName}`, logger_default.errorData(err));
|
|
2008
|
+
const state = this.budgets.get(mindName);
|
|
2009
|
+
if (state) state.queue.push(...messages);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
};
|
|
2013
|
+
var instance6 = null;
|
|
2014
|
+
function initTokenBudget() {
|
|
2015
|
+
if (instance6) throw new Error("TokenBudget already initialized");
|
|
2016
|
+
instance6 = new TokenBudget();
|
|
2017
|
+
return instance6;
|
|
2018
|
+
}
|
|
2019
|
+
function getTokenBudget() {
|
|
2020
|
+
if (!instance6) throw new Error("TokenBudget not initialized \u2014 call initTokenBudget() first");
|
|
2021
|
+
return instance6;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// src/lib/daemon/mind-service.ts
|
|
2025
|
+
async function startMindFull(name) {
|
|
2026
|
+
const [baseName, variantName] = name.split("@", 2);
|
|
2027
|
+
await getMindManager().startMind(name);
|
|
2028
|
+
publish({
|
|
2029
|
+
type: "mind_started",
|
|
2030
|
+
mind: name,
|
|
2031
|
+
summary: `${name} started`
|
|
2032
|
+
}).catch((err) => logger_default.error("failed to publish mind_started activity", logger_default.errorData(err)));
|
|
2033
|
+
if (variantName) return;
|
|
2034
|
+
const entry = findMind(baseName);
|
|
2035
|
+
if (!entry || entry.stage === "seed") return;
|
|
2036
|
+
const dir = mindDir(baseName);
|
|
2037
|
+
const daemonPort = process.env.VOLUTE_DAEMON_PORT ? parseInt(process.env.VOLUTE_DAEMON_PORT, 10) : void 0;
|
|
2038
|
+
await getConnectorManager().startConnectors(baseName, dir, entry.port, daemonPort);
|
|
2039
|
+
getScheduler().loadSchedules(baseName);
|
|
2040
|
+
ensureMailAddress(baseName).catch(
|
|
2041
|
+
(err) => logger_default.error(`failed to ensure mail address for ${baseName}`, logger_default.errorData(err))
|
|
2042
|
+
);
|
|
2043
|
+
const config = readVoluteConfig(dir);
|
|
2044
|
+
if (config?.tokenBudget) {
|
|
2045
|
+
getTokenBudget().setBudget(
|
|
2046
|
+
baseName,
|
|
2047
|
+
config.tokenBudget,
|
|
2048
|
+
config.tokenBudgetPeriodMinutes ?? DEFAULT_BUDGET_PERIOD_MINUTES
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
startWatcher(baseName);
|
|
2052
|
+
}
|
|
2053
|
+
async function sleepMind(name) {
|
|
2054
|
+
markIdle(name);
|
|
2055
|
+
await getMindManager().stopMind(name);
|
|
2056
|
+
publish({
|
|
2057
|
+
type: "mind_sleeping",
|
|
2058
|
+
mind: name,
|
|
2059
|
+
summary: `${name} is sleeping`
|
|
2060
|
+
}).catch((err) => logger_default.error("failed to publish mind_sleeping activity", logger_default.errorData(err)));
|
|
2061
|
+
}
|
|
2062
|
+
async function wakeMind(name) {
|
|
2063
|
+
await getMindManager().startMind(name);
|
|
2064
|
+
publish({
|
|
2065
|
+
type: "mind_waking",
|
|
2066
|
+
mind: name,
|
|
2067
|
+
summary: `${name} is waking`
|
|
2068
|
+
}).catch((err) => logger_default.error("failed to publish mind_waking activity", logger_default.errorData(err)));
|
|
2069
|
+
}
|
|
2070
|
+
async function stopMindFull(name) {
|
|
2071
|
+
const [baseName, variantName] = name.split("@", 2);
|
|
2072
|
+
if (!variantName) {
|
|
2073
|
+
stopWatcher(baseName);
|
|
2074
|
+
markIdle(baseName);
|
|
2075
|
+
await getConnectorManager().stopConnectors(baseName);
|
|
2076
|
+
getScheduler().unloadSchedules(baseName);
|
|
2077
|
+
getTokenBudget().removeBudget(baseName);
|
|
2078
|
+
}
|
|
2079
|
+
await getMindManager().stopMind(name);
|
|
2080
|
+
publish({
|
|
2081
|
+
type: "mind_stopped",
|
|
2082
|
+
mind: name,
|
|
2083
|
+
summary: `${name} stopped`
|
|
2084
|
+
}).catch((err) => logger_default.error("failed to publish mind_stopped activity", logger_default.errorData(err)));
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// src/lib/daemon/sleep-manager.ts
|
|
2088
|
+
var slog2 = logger_default.child("sleep");
|
|
2089
|
+
function defaultState() {
|
|
2090
|
+
return {
|
|
2091
|
+
sleeping: false,
|
|
2092
|
+
sleepingSince: null,
|
|
2093
|
+
scheduledWakeAt: null,
|
|
2094
|
+
wokenByTrigger: false,
|
|
2095
|
+
voluntaryWakeAt: null,
|
|
2096
|
+
queuedMessageCount: 0
|
|
2097
|
+
};
|
|
2098
|
+
}
|
|
2099
|
+
function formatCurrentDate() {
|
|
2100
|
+
return (/* @__PURE__ */ new Date()).toLocaleDateString("en-US", {
|
|
2101
|
+
weekday: "long",
|
|
2102
|
+
year: "numeric",
|
|
2103
|
+
month: "long",
|
|
2104
|
+
day: "numeric"
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
function formatDuration(from, to) {
|
|
2108
|
+
const ms = to.getTime() - from.getTime();
|
|
2109
|
+
const hours = Math.floor(ms / 36e5);
|
|
2110
|
+
const minutes = Math.floor(ms % 36e5 / 6e4);
|
|
2111
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
2112
|
+
return `${minutes}m`;
|
|
2113
|
+
}
|
|
2114
|
+
function matchesGlob(pattern, value) {
|
|
2115
|
+
const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
|
|
2116
|
+
return re.test(value);
|
|
2117
|
+
}
|
|
2118
|
+
var SleepManager = class {
|
|
2119
|
+
states = /* @__PURE__ */ new Map();
|
|
2120
|
+
interval = null;
|
|
2121
|
+
unsubActivity = null;
|
|
2122
|
+
transitioning = /* @__PURE__ */ new Set();
|
|
2123
|
+
get statePath() {
|
|
2124
|
+
return resolve7(voluteHome(), "sleep-state.json");
|
|
2125
|
+
}
|
|
2126
|
+
start() {
|
|
2127
|
+
this.loadState();
|
|
2128
|
+
this.interval = setInterval(() => this.tick(), 6e4);
|
|
2129
|
+
this.unsubActivity = subscribe((event) => this.onActivityEvent(event));
|
|
2130
|
+
}
|
|
2131
|
+
stop() {
|
|
2132
|
+
if (this.interval) clearInterval(this.interval);
|
|
2133
|
+
this.interval = null;
|
|
2134
|
+
if (this.unsubActivity) this.unsubActivity();
|
|
2135
|
+
this.unsubActivity = null;
|
|
2136
|
+
}
|
|
2137
|
+
// --- State persistence ---
|
|
2138
|
+
loadState() {
|
|
2139
|
+
try {
|
|
2140
|
+
if (existsSync5(this.statePath)) {
|
|
2141
|
+
const data = JSON.parse(readFileSync5(this.statePath, "utf-8"));
|
|
2142
|
+
for (const [name, state] of Object.entries(data)) {
|
|
2143
|
+
this.states.set(name, state);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
} catch (err) {
|
|
2147
|
+
slog2.warn("failed to load sleep state", logger_default.errorData(err));
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
saveState() {
|
|
2151
|
+
const data = {};
|
|
2152
|
+
for (const [name, state] of this.states) {
|
|
2153
|
+
if (state.sleeping) data[name] = state;
|
|
2154
|
+
}
|
|
2155
|
+
try {
|
|
2156
|
+
writeFileSync3(this.statePath, `${JSON.stringify(data, null, 2)}
|
|
2157
|
+
`);
|
|
2158
|
+
} catch (err) {
|
|
2159
|
+
slog2.error("failed to save sleep state", logger_default.errorData(err));
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
// --- Public API ---
|
|
2163
|
+
isSleeping(name) {
|
|
2164
|
+
const state = this.states.get(name);
|
|
2165
|
+
if (!state?.sleeping) return false;
|
|
2166
|
+
if (state.wokenByTrigger) return false;
|
|
2167
|
+
return true;
|
|
2168
|
+
}
|
|
2169
|
+
getState(name) {
|
|
2170
|
+
return this.states.get(name) ?? defaultState();
|
|
2171
|
+
}
|
|
2172
|
+
getSleepConfig(name) {
|
|
2173
|
+
const dir = mindDir(name);
|
|
2174
|
+
const config = readVoluteConfig(dir);
|
|
2175
|
+
return config?.sleep ?? null;
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Put a mind to sleep. Sends pre-sleep message, waits for completion,
|
|
2179
|
+
* archives session, then stops the mind process.
|
|
2180
|
+
*/
|
|
2181
|
+
async initiateSleep(name, opts) {
|
|
2182
|
+
if (this.isSleeping(name)) return;
|
|
2183
|
+
if (this.transitioning.has(name)) return;
|
|
2184
|
+
this.transitioning.add(name);
|
|
2185
|
+
try {
|
|
2186
|
+
const manager = getMindManager();
|
|
2187
|
+
if (!manager.isRunning(name)) {
|
|
2188
|
+
this.markSleeping(name, opts);
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
const entry = findMind(name);
|
|
2192
|
+
if (!entry) return;
|
|
2193
|
+
const sleepConfig = this.getSleepConfig(name);
|
|
2194
|
+
const wakeTime = opts?.voluntaryWakeAt ?? this.getNextWakeTime(sleepConfig) ?? "scheduled time";
|
|
2195
|
+
const queuedInfo = "";
|
|
2196
|
+
const preSleepMsg = await getPrompt("pre_sleep", { wakeTime, queuedInfo });
|
|
2197
|
+
try {
|
|
2198
|
+
const db = await getDb();
|
|
2199
|
+
await db.insert(mindHistory).values({
|
|
2200
|
+
mind: name,
|
|
2201
|
+
type: "inbound",
|
|
2202
|
+
channel: "system:sleep",
|
|
2203
|
+
content: preSleepMsg
|
|
2204
|
+
});
|
|
2205
|
+
} catch (err) {
|
|
2206
|
+
slog2.error(`failed to persist pre-sleep message for ${name}`, logger_default.errorData(err));
|
|
2207
|
+
}
|
|
2208
|
+
try {
|
|
2209
|
+
await fetch(`http://127.0.0.1:${entry.port}/message`, {
|
|
2210
|
+
method: "POST",
|
|
2211
|
+
headers: { "Content-Type": "application/json" },
|
|
2212
|
+
body: JSON.stringify({
|
|
2213
|
+
content: [{ type: "text", text: preSleepMsg }],
|
|
2214
|
+
channel: "system:sleep"
|
|
2215
|
+
})
|
|
2216
|
+
});
|
|
2217
|
+
} catch (err) {
|
|
2218
|
+
slog2.warn(`failed to send pre-sleep message to ${name}`, logger_default.errorData(err));
|
|
2219
|
+
}
|
|
2220
|
+
await this.waitForIdle(name, 12e4);
|
|
2221
|
+
await new Promise((r) => setTimeout(r, 3e3));
|
|
2222
|
+
await sleepMind(name);
|
|
2223
|
+
await this.killOrphanOnPort(entry.port);
|
|
2224
|
+
await this.archiveSessions(name);
|
|
2225
|
+
this.markSleeping(name, opts);
|
|
2226
|
+
slog2.info(`${name} is now sleeping`);
|
|
2227
|
+
} finally {
|
|
2228
|
+
this.transitioning.delete(name);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* Wake a sleeping mind. Starts the process, delivers wake summary.
|
|
2233
|
+
*/
|
|
2234
|
+
async initiateWake(name, opts) {
|
|
2235
|
+
const state = this.states.get(name);
|
|
2236
|
+
if (!state?.sleeping) return;
|
|
2237
|
+
if (this.transitioning.has(name)) return;
|
|
2238
|
+
this.transitioning.add(name);
|
|
2239
|
+
try {
|
|
2240
|
+
const sleepingSince = state.sleepingSince ? new Date(state.sleepingSince) : /* @__PURE__ */ new Date();
|
|
2241
|
+
const now = /* @__PURE__ */ new Date();
|
|
2242
|
+
const duration = formatDuration(sleepingSince, now);
|
|
2243
|
+
const currentDate = formatCurrentDate();
|
|
2244
|
+
const sleepTime = sleepingSince.toLocaleTimeString("en-US", {
|
|
2245
|
+
hour: "numeric",
|
|
2246
|
+
minute: "2-digit"
|
|
2247
|
+
});
|
|
2248
|
+
const queuedSummary = await this.buildQueuedSummary(name);
|
|
2249
|
+
try {
|
|
2250
|
+
await wakeMind(name);
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
slog2.error(`failed to wake ${name}`, logger_default.errorData(err));
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
const entry = findMind(name);
|
|
2256
|
+
if (!entry) return;
|
|
2257
|
+
let summaryText;
|
|
2258
|
+
if (opts?.trigger) {
|
|
2259
|
+
state.wokenByTrigger = true;
|
|
2260
|
+
summaryText = await getPrompt("wake_trigger_summary", {
|
|
2261
|
+
currentDate,
|
|
2262
|
+
triggerChannel: opts.trigger.channel,
|
|
2263
|
+
sleepTime,
|
|
2264
|
+
duration,
|
|
2265
|
+
queuedSummary
|
|
2266
|
+
});
|
|
2267
|
+
} else {
|
|
2268
|
+
summaryText = await getPrompt("wake_summary", {
|
|
2269
|
+
currentDate,
|
|
2270
|
+
sleepTime,
|
|
2271
|
+
duration,
|
|
2272
|
+
queuedSummary
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
try {
|
|
2276
|
+
const db = await getDb();
|
|
2277
|
+
await db.insert(mindHistory).values({
|
|
2278
|
+
mind: name,
|
|
2279
|
+
type: "inbound",
|
|
2280
|
+
channel: "system:sleep",
|
|
2281
|
+
content: summaryText
|
|
2282
|
+
});
|
|
2283
|
+
} catch (err) {
|
|
2284
|
+
slog2.error(`failed to persist wake summary for ${name}`, logger_default.errorData(err));
|
|
2285
|
+
}
|
|
2286
|
+
try {
|
|
2287
|
+
await fetch(`http://127.0.0.1:${entry.port}/message`, {
|
|
2288
|
+
method: "POST",
|
|
2289
|
+
headers: { "Content-Type": "application/json" },
|
|
2290
|
+
body: JSON.stringify({
|
|
2291
|
+
content: [{ type: "text", text: summaryText }],
|
|
2292
|
+
channel: "system:sleep"
|
|
2293
|
+
})
|
|
2294
|
+
});
|
|
2295
|
+
} catch (err) {
|
|
2296
|
+
slog2.warn(`failed to deliver wake summary to ${name}`, logger_default.errorData(err));
|
|
2297
|
+
}
|
|
2298
|
+
const flushed = await this.flushQueuedMessages(name);
|
|
2299
|
+
if (flushed > 0) {
|
|
2300
|
+
slog2.info(`flushed ${flushed} queued message(s) for ${name}`);
|
|
2301
|
+
}
|
|
2302
|
+
if (!opts?.trigger) {
|
|
2303
|
+
this.markAwake(name);
|
|
2304
|
+
}
|
|
2305
|
+
slog2.info(`${name} is now awake${opts?.trigger ? " (trigger wake)" : ""}`);
|
|
2306
|
+
} finally {
|
|
2307
|
+
this.transitioning.delete(name);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* Check if a message payload should trigger a wake.
|
|
2312
|
+
*/
|
|
2313
|
+
checkWakeTrigger(name, payload) {
|
|
2314
|
+
const config = this.getSleepConfig(name);
|
|
2315
|
+
const triggers = config?.wakeTriggers;
|
|
2316
|
+
const mentionsEnabled = triggers?.mentions !== false;
|
|
2317
|
+
const dmsEnabled = triggers?.dms !== false;
|
|
2318
|
+
if (dmsEnabled && payload.isDM) return true;
|
|
2319
|
+
if (mentionsEnabled && payload.content) {
|
|
2320
|
+
const text = typeof payload.content === "string" ? payload.content : Array.isArray(payload.content) ? payload.content.filter((b) => b.type === "text" && b.text).map((b) => b.text).join(" ") : "";
|
|
2321
|
+
if (text.includes(`@${name}`)) return true;
|
|
2322
|
+
}
|
|
2323
|
+
if (triggers?.channels) {
|
|
2324
|
+
for (const pattern of triggers.channels) {
|
|
2325
|
+
if (matchesGlob(pattern, payload.channel)) return true;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
if (triggers?.senders && payload.sender) {
|
|
2329
|
+
for (const pattern of triggers.senders) {
|
|
2330
|
+
if (matchesGlob(pattern, payload.sender)) return true;
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
return false;
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Queue a message for a sleeping mind in the delivery_queue table.
|
|
2337
|
+
*/
|
|
2338
|
+
async queueSleepMessage(name, payload) {
|
|
2339
|
+
const db = await getDb();
|
|
2340
|
+
await db.insert(deliveryQueue).values({
|
|
2341
|
+
mind: name,
|
|
2342
|
+
session: "sleep",
|
|
2343
|
+
channel: payload.channel,
|
|
2344
|
+
sender: payload.sender ?? null,
|
|
2345
|
+
status: "sleep-queued",
|
|
2346
|
+
payload: JSON.stringify(payload)
|
|
2347
|
+
});
|
|
2348
|
+
const state = this.states.get(name);
|
|
2349
|
+
if (state) {
|
|
2350
|
+
state.queuedMessageCount++;
|
|
2351
|
+
this.saveState();
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* Flush all queued sleep messages for a mind through the delivery manager.
|
|
2356
|
+
*/
|
|
2357
|
+
async flushQueuedMessages(name) {
|
|
2358
|
+
try {
|
|
2359
|
+
const db = await getDb();
|
|
2360
|
+
const rows = await db.select().from(deliveryQueue).where(and2(eq2(deliveryQueue.mind, name), eq2(deliveryQueue.status, "sleep-queued"))).all();
|
|
2361
|
+
if (rows.length === 0) return 0;
|
|
2362
|
+
const { deliverMessage: deliverMessage2 } = await import("./message-delivery-WUS4K4ZC.js");
|
|
2363
|
+
const delivered = [];
|
|
2364
|
+
for (const row of rows) {
|
|
2365
|
+
try {
|
|
2366
|
+
await deliverMessage2(name, JSON.parse(row.payload));
|
|
2367
|
+
delivered.push(row.id);
|
|
2368
|
+
} catch (err) {
|
|
2369
|
+
slog2.warn(`failed to flush queued message ${row.id} for ${name}`, logger_default.errorData(err));
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (delivered.length > 0) {
|
|
2373
|
+
await db.delete(deliveryQueue).where(inArray(deliveryQueue.id, delivered));
|
|
2374
|
+
}
|
|
2375
|
+
const state = this.states.get(name);
|
|
2376
|
+
if (state) {
|
|
2377
|
+
state.queuedMessageCount = Math.max(0, state.queuedMessageCount - delivered.length);
|
|
2378
|
+
}
|
|
2379
|
+
return delivered.length;
|
|
2380
|
+
} catch (err) {
|
|
2381
|
+
slog2.warn(`failed to flush queued messages for ${name}`, logger_default.errorData(err));
|
|
2382
|
+
return 0;
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
// --- Internal methods ---
|
|
2386
|
+
markSleeping(name, opts) {
|
|
2387
|
+
const sleepConfig = this.getSleepConfig(name);
|
|
2388
|
+
const state = {
|
|
2389
|
+
sleeping: true,
|
|
2390
|
+
sleepingSince: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2391
|
+
scheduledWakeAt: this.getNextWakeTime(sleepConfig),
|
|
2392
|
+
wokenByTrigger: false,
|
|
2393
|
+
voluntaryWakeAt: opts?.voluntaryWakeAt ?? null,
|
|
2394
|
+
queuedMessageCount: this.states.get(name)?.queuedMessageCount ?? 0
|
|
2395
|
+
};
|
|
2396
|
+
this.states.set(name, state);
|
|
2397
|
+
this.saveState();
|
|
2398
|
+
}
|
|
2399
|
+
markAwake(name) {
|
|
2400
|
+
this.states.delete(name);
|
|
2401
|
+
this.saveState();
|
|
2402
|
+
}
|
|
2403
|
+
getNextWakeTime(config) {
|
|
2404
|
+
if (!config?.schedule?.wake) return null;
|
|
2405
|
+
try {
|
|
2406
|
+
const interval = CronExpressionParser2.parse(config.schedule.wake);
|
|
2407
|
+
return interval.next().toDate().toISOString();
|
|
2408
|
+
} catch (err) {
|
|
2409
|
+
slog2.warn(`invalid wake cron "${config.schedule.wake}"`, logger_default.errorData(err));
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
tick() {
|
|
2414
|
+
const now = /* @__PURE__ */ new Date();
|
|
2415
|
+
const epochMinute = Math.floor(now.getTime() / 6e4);
|
|
2416
|
+
const registry = readRegistry();
|
|
2417
|
+
for (const entry of registry) {
|
|
2418
|
+
if (!entry.running && !this.isSleeping(entry.name)) continue;
|
|
2419
|
+
const config = this.getSleepConfig(entry.name);
|
|
2420
|
+
if (!config?.enabled || !config.schedule) continue;
|
|
2421
|
+
const state = this.states.get(entry.name);
|
|
2422
|
+
if (state?.sleeping && state.voluntaryWakeAt) {
|
|
2423
|
+
const wakeAt = new Date(state.voluntaryWakeAt);
|
|
2424
|
+
if (now >= wakeAt) {
|
|
2425
|
+
this.initiateWake(entry.name).catch(
|
|
2426
|
+
(err) => slog2.error(`failed voluntary wake for ${entry.name}`, logger_default.errorData(err))
|
|
2427
|
+
);
|
|
2428
|
+
continue;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
if (state?.sleeping && state.scheduledWakeAt) {
|
|
2432
|
+
const wakeAt = new Date(state.scheduledWakeAt);
|
|
2433
|
+
if (now >= wakeAt) {
|
|
2434
|
+
this.initiateWake(entry.name).catch(
|
|
2435
|
+
(err) => slog2.error(`failed scheduled wake for ${entry.name}`, logger_default.errorData(err))
|
|
2436
|
+
);
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
if (!state?.sleeping && entry.running) {
|
|
2441
|
+
if (this.shouldSleep(config.schedule.sleep, epochMinute)) {
|
|
2442
|
+
this.initiateSleep(entry.name).catch(
|
|
2443
|
+
(err) => slog2.error(`failed to initiate sleep for ${entry.name}`, logger_default.errorData(err))
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
shouldSleep(cronExpr, epochMinute) {
|
|
2450
|
+
try {
|
|
2451
|
+
const interval = CronExpressionParser2.parse(cronExpr);
|
|
2452
|
+
const prev = interval.prev().toDate();
|
|
2453
|
+
const prevMinute = Math.floor(prev.getTime() / 6e4);
|
|
2454
|
+
return prevMinute === epochMinute;
|
|
2455
|
+
} catch (err) {
|
|
2456
|
+
slog2.warn(`invalid sleep cron "${cronExpr}"`, logger_default.errorData(err));
|
|
2457
|
+
return false;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
async waitForIdle(name, timeoutMs) {
|
|
2461
|
+
return new Promise((resolve8) => {
|
|
2462
|
+
const timeout = setTimeout(() => {
|
|
2463
|
+
unsub();
|
|
2464
|
+
resolve8();
|
|
2465
|
+
}, timeoutMs);
|
|
2466
|
+
const unsub = subscribe((event) => {
|
|
2467
|
+
if (event.mind !== name) return;
|
|
2468
|
+
if (event.type === "mind_done" || event.type === "mind_idle") {
|
|
2469
|
+
clearTimeout(timeout);
|
|
2470
|
+
unsub();
|
|
2471
|
+
resolve8();
|
|
2472
|
+
}
|
|
2473
|
+
});
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
async archiveSessions(name) {
|
|
2477
|
+
const dir = mindDir(name);
|
|
2478
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 16);
|
|
2479
|
+
const sessionsDir = resolve7(dir, ".mind", "sessions");
|
|
2480
|
+
if (existsSync5(sessionsDir)) {
|
|
2481
|
+
const archiveDir = resolve7(sessionsDir, "archive");
|
|
2482
|
+
mkdirSync3(archiveDir, { recursive: true });
|
|
2483
|
+
for (const file of readdirSync2(sessionsDir)) {
|
|
2484
|
+
if (file === "archive" || !file.endsWith(".json")) continue;
|
|
2485
|
+
const src = resolve7(sessionsDir, file);
|
|
2486
|
+
const base = file.replace(/\.json$/, "");
|
|
2487
|
+
const dest = resolve7(archiveDir, `${base}-${timestamp}.json`);
|
|
2488
|
+
try {
|
|
2489
|
+
renameSync(src, dest);
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
slog2.warn(`failed to archive session ${file} for ${name}`, logger_default.errorData(err));
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
const piSessionsDir = resolve7(dir, ".mind", "pi-sessions");
|
|
2496
|
+
if (existsSync5(piSessionsDir)) {
|
|
2497
|
+
const archiveDir = resolve7(piSessionsDir, "archive");
|
|
2498
|
+
mkdirSync3(archiveDir, { recursive: true });
|
|
2499
|
+
for (const entry of readdirSync2(piSessionsDir, { withFileTypes: true })) {
|
|
2500
|
+
if (entry.name === "archive" || !entry.isDirectory()) continue;
|
|
2501
|
+
const src = resolve7(piSessionsDir, entry.name);
|
|
2502
|
+
const dest = resolve7(archiveDir, `${entry.name}-${timestamp}`);
|
|
2503
|
+
try {
|
|
2504
|
+
renameSync(src, dest);
|
|
2505
|
+
} catch (err) {
|
|
2506
|
+
slog2.warn(`failed to archive pi-session ${entry.name} for ${name}`, logger_default.errorData(err));
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
async buildQueuedSummary(name) {
|
|
2512
|
+
try {
|
|
2513
|
+
const db = await getDb();
|
|
2514
|
+
const rows = await db.select({ channel: deliveryQueue.channel }).from(deliveryQueue).where(and2(eq2(deliveryQueue.mind, name), eq2(deliveryQueue.status, "sleep-queued"))).all();
|
|
2515
|
+
if (rows.length === 0) return "No messages while you slept.";
|
|
2516
|
+
const channelCounts = /* @__PURE__ */ new Map();
|
|
2517
|
+
for (const row of rows) {
|
|
2518
|
+
const ch = row.channel ?? "unknown";
|
|
2519
|
+
channelCounts.set(ch, (channelCounts.get(ch) ?? 0) + 1);
|
|
2520
|
+
}
|
|
2521
|
+
const parts = [...channelCounts.entries()].map(([ch, count]) => `${count} on ${ch}`);
|
|
2522
|
+
return `${rows.length} message${rows.length === 1 ? "" : "s"} while you slept (${parts.join(", ")}). Ask if you want them delivered.`;
|
|
2523
|
+
} catch (err) {
|
|
2524
|
+
slog2.warn(`failed to build queued summary for ${name}`, logger_default.errorData(err));
|
|
2525
|
+
return "No messages while you slept.";
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
/**
|
|
2529
|
+
* Kill any process still listening on a port after stopMind.
|
|
2530
|
+
* Handles the case where a hook (e.g. identity-reload) restarted the server.
|
|
2531
|
+
*/
|
|
2532
|
+
async killOrphanOnPort(port) {
|
|
2533
|
+
try {
|
|
2534
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
2535
|
+
if (!res.ok) return;
|
|
2536
|
+
} catch {
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
slog2.warn(`orphan process found on port ${port} after sleep, killing`);
|
|
2540
|
+
const execFileAsync = promisify(execFile);
|
|
2541
|
+
try {
|
|
2542
|
+
const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`, "-sTCP:LISTEN"]);
|
|
2543
|
+
for (const line of stdout.trim().split("\n").filter(Boolean)) {
|
|
2544
|
+
const pid = parseInt(line, 10);
|
|
2545
|
+
if (pid > 0) {
|
|
2546
|
+
try {
|
|
2547
|
+
process.kill(pid, "SIGTERM");
|
|
2548
|
+
} catch (err) {
|
|
2549
|
+
if (err.code !== "ESRCH") {
|
|
2550
|
+
slog2.warn(`failed to kill orphan pid ${pid}`, logger_default.errorData(err));
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
} catch {
|
|
2556
|
+
try {
|
|
2557
|
+
const portHex = port.toString(16).toUpperCase().padStart(4, "0");
|
|
2558
|
+
const tcp6 = readFileSync5("/proc/net/tcp6", "utf-8");
|
|
2559
|
+
for (const line of tcp6.split("\n")) {
|
|
2560
|
+
if (!line.includes(`:${portHex} `)) continue;
|
|
2561
|
+
const fields = line.trim().split(/\s+/);
|
|
2562
|
+
if (fields[3] !== "0A") continue;
|
|
2563
|
+
const inode = parseInt(fields[9], 10);
|
|
2564
|
+
if (!inode) continue;
|
|
2565
|
+
for (const pidDir of readdirSync2("/proc").filter((f) => /^\d+$/.test(f))) {
|
|
2566
|
+
try {
|
|
2567
|
+
const fds = readdirSync2(`/proc/${pidDir}/fd`);
|
|
2568
|
+
for (const fd of fds) {
|
|
2569
|
+
try {
|
|
2570
|
+
const link = readlinkSync(`/proc/${pidDir}/fd/${fd}`);
|
|
2571
|
+
if (link.includes(`socket:[${inode}]`)) {
|
|
2572
|
+
process.kill(parseInt(pidDir, 10), "SIGTERM");
|
|
2573
|
+
}
|
|
2574
|
+
} catch {
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
} catch {
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
} catch (err) {
|
|
2582
|
+
slog2.warn(`failed to kill orphan on port ${port} via /proc`, logger_default.errorData(err));
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2586
|
+
}
|
|
2587
|
+
onActivityEvent(event) {
|
|
2588
|
+
const state = this.states.get(event.mind);
|
|
2589
|
+
if (!state?.sleeping || !state.wokenByTrigger) return;
|
|
2590
|
+
if (this.transitioning.has(event.mind)) return;
|
|
2591
|
+
if (event.type === "mind_idle") {
|
|
2592
|
+
slog2.info(`${event.mind} going back to sleep after trigger wake`);
|
|
2593
|
+
state.wokenByTrigger = false;
|
|
2594
|
+
this.transitioning.add(event.mind);
|
|
2595
|
+
sleepMind(event.mind).then(() => this.archiveSessions(event.mind)).then(() => {
|
|
2596
|
+
state.sleeping = true;
|
|
2597
|
+
state.sleepingSince = (/* @__PURE__ */ new Date()).toISOString();
|
|
2598
|
+
const sleepConfig = this.getSleepConfig(event.mind);
|
|
2599
|
+
state.scheduledWakeAt = this.getNextWakeTime(sleepConfig);
|
|
2600
|
+
this.saveState();
|
|
2601
|
+
slog2.info(`${event.mind} returned to sleep`);
|
|
2602
|
+
}).catch((err) => {
|
|
2603
|
+
slog2.error(`failed to return ${event.mind} to sleep`, logger_default.errorData(err));
|
|
2604
|
+
}).finally(() => {
|
|
2605
|
+
this.transitioning.delete(event.mind);
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
};
|
|
2610
|
+
var instance7 = null;
|
|
2611
|
+
function initSleepManager() {
|
|
2612
|
+
if (instance7) throw new Error("SleepManager already initialized");
|
|
2613
|
+
instance7 = new SleepManager();
|
|
2614
|
+
return instance7;
|
|
2615
|
+
}
|
|
2616
|
+
function getSleepManager() {
|
|
2617
|
+
if (!instance7) throw new Error("SleepManager not initialized \u2014 call initSleepManager() first");
|
|
2618
|
+
return instance7;
|
|
2619
|
+
}
|
|
2620
|
+
function getSleepManagerIfReady() {
|
|
2621
|
+
return instance7;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
export {
|
|
2625
|
+
initConnectorManager,
|
|
2626
|
+
getConnectorManager,
|
|
2627
|
+
stopAllWatchers,
|
|
2628
|
+
getCachedSites,
|
|
2629
|
+
getCachedRecentPages,
|
|
2630
|
+
initScheduler,
|
|
2631
|
+
getScheduler,
|
|
2632
|
+
initTokenBudget,
|
|
2633
|
+
getTokenBudget,
|
|
2634
|
+
startMindFull,
|
|
2635
|
+
stopMindFull,
|
|
2636
|
+
matchesGlob,
|
|
2637
|
+
SleepManager,
|
|
2638
|
+
initSleepManager,
|
|
2639
|
+
getSleepManager,
|
|
2640
|
+
getSleepManagerIfReady,
|
|
2641
|
+
subscribe2 as subscribe,
|
|
2642
|
+
publish2 as publish,
|
|
2643
|
+
getTypingMap,
|
|
2644
|
+
publishTypingForChannels,
|
|
2645
|
+
extractTextContent,
|
|
2646
|
+
initDeliveryManager,
|
|
2647
|
+
getDeliveryManager,
|
|
2648
|
+
deliverMessage,
|
|
2649
|
+
initMailPoller,
|
|
2650
|
+
getMailPoller
|
|
2651
|
+
};
|