volute 0.49.0 → 0.51.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 +2 -2
- package/dist/{accept-KYNC5X3U.js → accept-5NK5XJB5.js} +2 -1
- package/dist/{activity-events-GJJAI2WT.js → activity-events-LQII6PZS.js} +3 -3
- package/dist/{ai-service-A7FS7EC3.js → ai-service-YW5SWCSB.js} +9 -4
- package/dist/{archive-7E3YE6VF.js → archive-FOHGXHPT.js} +3 -3
- package/dist/{auth-ABYLOEZ7.js → auth-X6EMNEGB.js} +10 -6
- package/dist/{avatar-image-6ATV2IOZ.js → avatar-image-52WGXLHV.js} +3 -3
- package/dist/{backup-XYAMT4CU.js → backup-YVGV2ZNM.js} +11 -12
- package/dist/{bridge-3FNSWLNQ.js → bridge-YK7NTRU5.js} +2 -1
- package/dist/{channels-SGQZHNQJ.js → channels-FSAHOQWI.js} +2 -1
- package/dist/{chat-OGXAEXMV.js → chat-VAZ25KVT.js} +9 -9
- package/dist/{chunk-P4LHVRNE.js → chunk-2RNMBXD2.js} +43 -19
- package/dist/{chunk-6HGEZJDH.js → chunk-5N32EN4O.js} +2 -2
- package/dist/{chunk-JCWB2ITW.js → chunk-5Y54AE3L.js} +35 -5
- package/dist/{chunk-HCYQRME2.js → chunk-6NLW72HP.js} +2 -2
- package/dist/chunk-7HN63Q7J.js +1317 -0
- package/dist/chunk-7YJY3F2R.js +38 -0
- package/dist/{chunk-EADX54RG.js → chunk-AI47FDGL.js} +6 -7
- package/dist/{chunk-IH5TKXX6.js → chunk-BSHZ23MS.js} +12 -11
- package/dist/{chunk-K3JOLGWF.js → chunk-CDKQJWGK.js} +76 -63
- package/dist/{chunk-L27YYONJ.js → chunk-CV6BNGFK.js} +3 -3
- package/dist/{chunk-NOIAHMJB.js → chunk-DMK5ASKV.js} +1 -1
- package/dist/chunk-DN3LKLYR.js +109 -0
- package/dist/{chunk-JBIFXB6I.js → chunk-EDR47UE2.js} +3 -5
- package/dist/{chunk-SHKM5RGD.js → chunk-EEOMA4DX.js} +1 -1
- package/dist/{chunk-YIJGKHIS.js → chunk-EGV6R6UF.js} +43 -20
- package/dist/{chunk-KHCKZ36O.js → chunk-FLIEXG2T.js} +1 -1
- package/dist/chunk-FNJCBRD3.js +31 -0
- package/dist/{chunk-JPGHSXSV.js → chunk-FXWBFDQR.js} +23 -9
- package/dist/{chunk-JCBPU57A.js → chunk-G7A5LEVI.js} +6 -6
- package/dist/{chunk-PY24ZL62.js → chunk-GLM7TVWP.js} +1 -1
- package/dist/{chunk-RSBL3FAD.js → chunk-H6X6CKDN.js} +235 -24
- package/dist/{chunk-4DSGN4FL.js → chunk-HKP6OAJP.js} +43 -13
- package/dist/{chunk-C3KLHJDB.js → chunk-HTRSSOHY.js} +1 -1
- package/dist/{chunk-EG5LSKHU.js → chunk-I7U5EEZC.js} +2 -2
- package/dist/chunk-ICL6T4SN.js +562 -0
- package/dist/{chunk-FBBS4BVR.js → chunk-ICXY54ZC.js} +1 -1
- package/dist/{chunk-DAIIMBLB.js → chunk-IWAVIJO2.js} +3 -5
- package/dist/{chunk-TXJL36ZQ.js → chunk-JHKBALS2.js} +2 -2
- package/dist/{chunk-MG3Z34YE.js → chunk-L7BKTZ5U.js} +1 -1
- package/dist/{chunk-SX72DOO3.js → chunk-LAUCO2HC.js} +1 -1
- package/dist/chunk-LNNI3MS4.js +54 -0
- package/dist/{chunk-BCG45AQB.js → chunk-M74I3HHF.js} +15 -5
- package/dist/{chunk-DVN6DNMI.js → chunk-M77B73VV.js} +17 -2
- package/dist/{chunk-4B62E4CG.js → chunk-OXWUEU5V.js} +2 -2
- package/dist/chunk-Q7XQLKZB.js +289 -0
- package/dist/{chunk-GWVFDUNB.js → chunk-QT4D5XCI.js} +8 -10
- package/dist/{chunk-VNRSXEBF.js → chunk-TPX6E6PL.js} +38 -5
- package/dist/{chunk-3B427GUK.js → chunk-U54PJPTB.js} +2 -2
- package/dist/{chunk-EFT2OALG.js → chunk-UDZLPR34.js} +20 -5
- package/dist/{chunk-ZX46PGCC.js → chunk-UFTDRTCP.js} +1 -1
- package/dist/chunk-VAEZCIJC.js +1058 -0
- package/dist/chunk-VP5PFNZO.js +2905 -0
- package/dist/{chunk-VIUH5CHQ.js → chunk-VSBMLB2W.js} +51 -34
- package/dist/{chunk-RRZ4RQQU.js → chunk-X67BDDZR.js} +5 -7
- package/dist/cli.js +34 -27
- package/dist/{clock-6JZL2FS6.js → clock-ZT5YT6HA.js} +4 -3
- package/dist/{cloud-sync-VFDTNNCA.js → cloud-sync-GPXYPTIC.js} +23 -20
- package/dist/{config-RPSLA2HX.js → config-UGJS57VT.js} +3 -3
- package/dist/{conversations-TUM5PJJ4.js → conversations-ZL6PHROD.js} +8 -4
- package/dist/{create-Z5FSDNVZ.js → create-G2RIKQYK.js} +2 -2
- package/dist/{create-VIQVP3QB.js → create-M727VUDF.js} +2 -1
- package/dist/{daemon-client-ZFY5MOAL.js → daemon-client-VVYFZI2D.js} +2 -1
- package/dist/{daemon-restart-54L4B6AQ.js → daemon-restart-2EWBRTN5.js} +6 -7
- package/dist/daemon.js +2005 -762
- package/dist/{db-F6UMFRYD.js → db-BONH4SUG.js} +2 -2
- package/dist/{delete-ESEP7NGQ.js → delete-O7PEFQDF.js} +1 -1
- package/dist/delivery-manager-HGVZSEES.js +35 -0
- package/dist/{delivery-router-VUOL5YSA.js → delivery-router-RHANNTAC.js} +7 -5
- package/dist/{down-6X7KX7BZ.js → down-QSNEMTTX.js} +5 -5
- package/dist/echo-text-6ANI5HHN.js +39 -0
- package/dist/{env-ZYMSCB7B.js → env-IBW2QI6K.js} +3 -2
- package/dist/{env-KUL4XKN2.js → env-SQFCWW52.js} +3 -3
- package/dist/{exec-FBXA3CM4.js → exec-ABD3DYA2.js} +3 -3
- package/dist/{export-TNBPDH77.js → export-VBNWM7I4.js} +4 -4
- package/dist/{extension-J4ZQ2GHV.js → extension-MRYJWSMV.js} +2 -1
- package/dist/{extensions-AWNO3SZF.js → extensions-NY6WST33.js} +23 -20
- package/dist/{files-BVBRJJ2P.js → files-BRY5RRIM.js} +5 -4
- package/dist/{import-ZUNKMCKC.js → import-DB2KULRO.js} +8 -8
- package/dist/{isolation-OPCZQRTH.js → isolation-GVV2IBAU.js} +5 -3
- package/dist/{join-34TR2GXQ.js → join-PYA36HEC.js} +1 -1
- package/dist/{list-PMUXZNAM.js → list-UQGIW7OF.js} +8 -3
- package/dist/{login-LKNLCJPA.js → login-RJJTCVR3.js} +2 -1
- package/dist/{login-KAKNFCW5.js → login-SB5OENMV.js} +4 -3
- package/dist/{logout-YZRMZ3O2.js → logout-BYV433X6.js} +2 -1
- package/dist/{logout-RLT62RQP.js → logout-YFZAJRPY.js} +4 -3
- package/dist/message-delivery-JHMAJ2GU.js +43 -0
- package/dist/migrate-thread-config-EQSEP2XG.js +90 -0
- package/dist/{mind-IF6CGMFW.js → mind-WF6QIXWV.js} +19 -19
- package/dist/{mind-activity-tracker-YMMERB6B.js → mind-activity-tracker-QF6DCX2X.js} +4 -4
- package/dist/{mind-contacts-BKUFPUCU.js → mind-contacts-QYU4TPEA.js} +3 -2
- package/dist/{mind-history-WI5OI3BW.js → mind-history-OGNR4L7W.js} +30 -7
- package/dist/{mind-list-OTHV4ZO4.js → mind-list-SYRA7CH5.js} +3 -2
- package/dist/{mind-profile-LUG353ZT.js → mind-profile-MRCVZPYT.js} +1 -1
- package/dist/mind-service-DZCUUC7J.js +47 -0
- package/dist/{mind-sleep-VBV7YRSU.js → mind-sleep-LZAFBDZD.js} +4 -3
- package/dist/{mind-status-BJNPGXKC.js → mind-status-Z2LQFA6F.js} +3 -2
- package/dist/{mind-wake-LVTZ55EZ.js → mind-wake-LDGPRA42.js} +2 -1
- package/dist/{package-PD67VPHB.js → package-B6O5APCZ.js} +23 -23
- package/dist/{prompts-MDHDLWEX.js → prompts-NBO3HRK3.js} +3 -3
- package/dist/{read-YSWUXYL7.js → read-RJ5NAW5J.js} +5 -14
- package/dist/{register-TWQDKQRT.js → register-OX4HRSGC.js} +2 -1
- package/dist/{registry-3ELJPUGJ.js → registry-TRC2HJXR.js} +6 -4
- package/dist/{reject-SXGCYN4A.js → reject-TWB4QJHG.js} +2 -1
- package/dist/{restart-5F5QDBOG.js → restart-AGEGNSYQ.js} +2 -1
- package/dist/{sandbox-XPYA7UIF.js → sandbox-NNWCTHRI.js} +7 -4
- package/dist/scheduler-TNBN54ZK.js +25 -0
- package/dist/{schema-6YONVTKZ.js → schema-ZZQVM4GR.js} +5 -3
- package/dist/{seed-NQED7PSX.js → seed-K2LYODIH.js} +1 -1
- package/dist/{seed-check-TYD7RRZY.js → seed-check-NRJEQZST.js} +14 -5
- package/dist/{seed-cmd-QF3F2G4S.js → seed-cmd-2EWBTR6P.js} +3 -3
- package/dist/{seed-create-XOO4N2OE.js → seed-create-VGTLU5FD.js} +4 -4
- package/dist/{seed-readiness-3LRYASA4.js → seed-readiness-DYOVCYHN.js} +4 -5
- package/dist/{seed-sprout-HSBBGSS2.js → seed-sprout-PILHJ3YS.js} +9 -9
- package/dist/{send-IHRJYNXA.js → send-2YKOZJJ5.js} +108 -54
- package/dist/{service-YCOPCTSZ.js → service-XQPMZAGI.js} +4 -4
- package/dist/{service-install-Y46OTF55.js → service-install-2EWD7KXD.js} +5 -5
- package/dist/{setup-F4TE64YI.js → setup-MDPSK2XG.js} +6 -7
- package/dist/{setup-TCN7VH5U.js → setup-WZSXHHRJ.js} +10 -3
- package/dist/{skill-4CYJLYVY.js → skill-B5OJEQUA.js} +3 -2
- package/dist/skills/dreaming/references/INSTALL.md +2 -2
- package/dist/skills/imagegen/SKILL.md +21 -4
- package/dist/skills/imagegen/scripts/imagegen.ts +124 -21
- package/dist/skills/tending/SKILL.md +7 -7
- package/dist/skills/volute-mind/SKILL.md +3 -3
- package/dist/skills/volute-mind/references/integrations.md +2 -2
- package/dist/skills/volute-mind/references/routing.md +15 -15
- package/dist/{skills-NN5UZCUU.js → skills-NH2GQKAF.js} +4 -5
- package/dist/sleep-manager-7JXEQQ2P.js +41 -0
- package/dist/{spirit-SSJMYC6A.js → spirit-25JVAL3M.js} +11 -8
- package/dist/spirit-availability-PJDWZJ3J.js +42 -0
- package/dist/{split-JF2VWTXA.js → split-BU5VWX6P.js} +1 -1
- package/dist/{sprout-AXA2SECQ.js → sprout-EXUZMIOR.js} +1 -1
- package/dist/{src-YH6PQ7LJ.js → src-HGNE2XG4.js} +7 -1
- package/dist/{src-ZN4LXMPI.js → src-LCSOQY6Z.js} +57 -51
- package/dist/{src-JAOVXO5W.js → src-UDMJZ6MH.js} +8 -8
- package/dist/{start-BTVMTP3K.js → start-MUMNFR52.js} +2 -1
- package/dist/{status-VJGXMQ4L.js → status-MWLE3RVC.js} +7 -6
- package/dist/{stop-YORESK4U.js → stop-EKERNYTX.js} +2 -1
- package/dist/system-events-ZPSH63QX.js +46 -0
- package/dist/system-install-YZSWCPDK.js +8 -0
- package/dist/{systems-5MH2ELQE.js → systems-VDRUZXNF.js} +5 -4
- package/dist/{tailscale-AMMVKDXB.js → tailscale-KHNZFC7L.js} +2 -2
- package/dist/{template-hash-GSOUARW4.js → template-hash-YKWMVYMM.js} +2 -2
- package/dist/{template-staleness-HKGCTS3H.js → template-staleness-PY2CULH6.js} +5 -5
- package/dist/{turn-tracker-JQ2YUUY2.js → turn-tracker-TAT2HGK6.js} +6 -6
- package/dist/{up-SDLTLPAO.js → up-J7XCAH7P.js} +5 -6
- package/dist/{update-WEA4N2TR.js → update-CIWI2JYA.js} +5 -5
- package/dist/{update-check-DZOTONII.js → update-check-SZ7B5IFI.js} +3 -3
- package/dist/{upgrade-K5FIQPQD.js → upgrade-LIE46UBB.js} +1 -1
- package/dist/variant-cleanup-YXN25ZVW.js +22 -0
- package/dist/{version-notify-WFA5XB5A.js → version-notify-EN3KJ7HH.js} +11 -28
- package/dist/web-assets/assets/index-BOyJ4Gqs.css +1 -0
- package/dist/web-assets/assets/index-CrxBwnzp.js +95 -0
- package/dist/web-assets/index.html +2 -2
- package/dist/xai-P42CRCZA.js +129 -0
- package/drizzle/0014_system_events.sql +26 -0
- package/drizzle/0015_rename_session_to_thread.sql +12 -0
- package/drizzle/0016_brain_to_human.sql +4 -0
- package/drizzle/0017_event_history_rows.sql +9 -0
- package/drizzle/0018_api_tokens.sql +20 -0
- package/drizzle/meta/_journal.json +35 -0
- package/package.json +23 -23
- package/packages/extensions/notes/dist/ui/assets/index-BBs8pyel.js +67 -0
- package/packages/extensions/notes/dist/ui/index.html +1 -1
- package/packages/extensions/pages/dist/ui/assets/index-B589e8Xe.js +2 -0
- package/packages/extensions/pages/dist/ui/index.html +1 -1
- package/packages/extensions/plan/dist/ui/assets/index-B8s8wxyt.js +67 -0
- package/packages/extensions/plan/dist/ui/index.html +1 -1
- package/templates/_base/.init/.config/prompts.json +1 -0
- package/templates/_base/.init/.local/hooks/startup-context.ts +24 -9
- package/templates/_base/{biome.json.tmpl → biome.json} +2 -1
- package/templates/_base/gitignore +44 -0
- package/templates/_base/home/.config/routes.json +2 -2
- package/templates/_base/home/VOLUTE.md +18 -5
- package/templates/_base/home/public/.gitkeep +0 -0
- package/templates/_base/src/lib/daemon-client.ts +1 -1
- package/templates/_base/src/lib/event-turn.ts +39 -0
- package/templates/_base/src/lib/format-prefix.ts +36 -10
- package/templates/_base/src/lib/router.ts +19 -10
- package/templates/_base/src/lib/routing.ts +8 -8
- package/templates/_base/src/lib/startup.ts +9 -1
- package/templates/_base/src/lib/types.ts +7 -1
- package/templates/_base/src/lib/volute-server.ts +30 -1
- package/templates/claude/.init/.config/routes.json +3 -3
- package/templates/claude/.init/CLAUDE.md +1 -1
- package/templates/claude/{package.json.tmpl → package.json} +4 -4
- package/templates/claude/src/agent.ts +4 -1
- package/templates/claude/src/lib/hooks/reply-instructions.ts +26 -1
- package/templates/claude/volute-template.json +1 -3
- package/templates/codex/.init/.config/routes.json +3 -3
- package/templates/codex/.init/AGENTS.md +1 -1
- package/templates/codex/{package.json.tmpl → package.json} +4 -4
- package/templates/codex/src/agent.ts +11 -11
- package/templates/codex/src/lib/turn-context.ts +51 -0
- package/templates/codex/volute-template.json +1 -3
- package/templates/pi/.init/.config/routes.json +3 -3
- package/templates/pi/.init/MINDS.md +1 -1
- package/templates/pi/{package.json.tmpl → package.json} +6 -6
- package/templates/pi/src/agent.ts +9 -1
- package/templates/pi/src/lib/event-handler.ts +1 -1
- package/templates/pi/src/lib/reply-instructions-extension.ts +28 -1
- package/templates/pi/volute-template.json +1 -3
- package/dist/chunk-5T5YMX6S.js +0 -23
- package/dist/chunk-NJHN6IRB.js +0 -177
- package/dist/chunk-QQBOI3QA.js +0 -5345
- package/dist/chunk-XTEKINWG.js +0 -158
- package/dist/delivery-manager-RBXBPVWM.js +0 -36
- package/dist/echo-text-B6NZ5FGE.js +0 -36
- package/dist/message-delivery-ABPV76EA.js +0 -42
- package/dist/mind-service-42GDO4KH.js +0 -44
- package/dist/notices-N6BCGHFV.js +0 -25
- package/dist/scheduler-K7Y7Z7FQ.js +0 -34
- package/dist/sleep-manager-DSPXFHGX.js +0 -38
- package/dist/system-chat-M2XOBKEM.js +0 -40
- package/dist/variant-cleanup-P4MBNLJD.js +0 -33
- package/dist/web-assets/assets/index-BostMFoR.css +0 -1
- package/dist/web-assets/assets/index-CKlqUkyG.js +0 -92
- package/packages/extensions/notes/dist/ui/assets/index-D0-DgYyj.js +0 -67
- package/packages/extensions/pages/dist/ui/assets/index-DtzftuPA.js +0 -2
- package/packages/extensions/plan/dist/ui/assets/index-Cfzs-0G9.js +0 -67
- /package/templates/_base/home/.config/{config.json.tmpl → config.json} +0 -0
- /package/templates/codex/home/.config/{config.json.tmpl → config.json} +0 -0
- /package/templates/pi/home/.config/{config.json.tmpl → config.json} +0 -0
|
@@ -0,0 +1,1317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
tryGetMindManager
|
|
4
|
+
} from "./chunk-VAEZCIJC.js";
|
|
5
|
+
import {
|
|
6
|
+
extractTextContent,
|
|
7
|
+
getRoutingConfig,
|
|
8
|
+
resolveDeliveryMode,
|
|
9
|
+
resolveRoute,
|
|
10
|
+
setRoutesChangeListener,
|
|
11
|
+
shouldGate
|
|
12
|
+
} from "./chunk-UDZLPR34.js";
|
|
13
|
+
import {
|
|
14
|
+
linkInboundToActiveTurn
|
|
15
|
+
} from "./chunk-CDKQJWGK.js";
|
|
16
|
+
import {
|
|
17
|
+
MIND_LEVEL_THREAD,
|
|
18
|
+
newEphemeralSession
|
|
19
|
+
} from "./chunk-ICL6T4SN.js";
|
|
20
|
+
import {
|
|
21
|
+
publish
|
|
22
|
+
} from "./chunk-LNNI3MS4.js";
|
|
23
|
+
import {
|
|
24
|
+
getParticipants,
|
|
25
|
+
publish as publish2
|
|
26
|
+
} from "./chunk-TPX6E6PL.js";
|
|
27
|
+
import {
|
|
28
|
+
onMindEvent
|
|
29
|
+
} from "./chunk-UFTDRTCP.js";
|
|
30
|
+
import {
|
|
31
|
+
renderAvatarBlock
|
|
32
|
+
} from "./chunk-ICXY54ZC.js";
|
|
33
|
+
import {
|
|
34
|
+
readVoluteConfig
|
|
35
|
+
} from "./chunk-WCFSBH33.js";
|
|
36
|
+
import {
|
|
37
|
+
logger_default
|
|
38
|
+
} from "./chunk-QUSMYGNQ.js";
|
|
39
|
+
import {
|
|
40
|
+
findMind,
|
|
41
|
+
getBaseName,
|
|
42
|
+
getDb,
|
|
43
|
+
mindDir,
|
|
44
|
+
voluteHome
|
|
45
|
+
} from "./chunk-H6X6CKDN.js";
|
|
46
|
+
import {
|
|
47
|
+
channelGates,
|
|
48
|
+
deliveryQueue,
|
|
49
|
+
messages,
|
|
50
|
+
mindHistory
|
|
51
|
+
} from "./chunk-2RNMBXD2.js";
|
|
52
|
+
|
|
53
|
+
// packages/daemon/src/lib/delivery/delivery-manager.ts
|
|
54
|
+
import { realpath } from "fs/promises";
|
|
55
|
+
import { resolve } from "path";
|
|
56
|
+
import { and as and2, eq as eq2, inArray, sql } from "drizzle-orm";
|
|
57
|
+
|
|
58
|
+
// packages/daemon/src/lib/chat/typing.ts
|
|
59
|
+
var DEFAULT_TTL_MS = 1e4;
|
|
60
|
+
var SWEEP_INTERVAL_MS = 5e3;
|
|
61
|
+
var TypingMap = class {
|
|
62
|
+
channels = /* @__PURE__ */ new Map();
|
|
63
|
+
sweepTimer;
|
|
64
|
+
constructor() {
|
|
65
|
+
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
|
66
|
+
this.sweepTimer.unref();
|
|
67
|
+
}
|
|
68
|
+
set(channel, sender, opts) {
|
|
69
|
+
const expiresAt = opts?.persistent ? Infinity : Date.now() + (opts?.ttlMs ?? DEFAULT_TTL_MS);
|
|
70
|
+
let senders = this.channels.get(channel);
|
|
71
|
+
if (!senders) {
|
|
72
|
+
senders = /* @__PURE__ */ new Map();
|
|
73
|
+
this.channels.set(channel, senders);
|
|
74
|
+
}
|
|
75
|
+
senders.set(sender, { expiresAt });
|
|
76
|
+
}
|
|
77
|
+
delete(channel, sender) {
|
|
78
|
+
const senders = this.channels.get(channel);
|
|
79
|
+
if (senders) {
|
|
80
|
+
senders.delete(sender);
|
|
81
|
+
if (senders.size === 0) {
|
|
82
|
+
this.channels.delete(channel);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Remove a sender from all channels (e.g. when a mind finishes processing). Returns affected channel names. */
|
|
87
|
+
deleteSender(sender) {
|
|
88
|
+
const affected = [];
|
|
89
|
+
for (const [channel, senders] of this.channels) {
|
|
90
|
+
if (senders.has(sender)) {
|
|
91
|
+
senders.delete(sender);
|
|
92
|
+
affected.push(channel);
|
|
93
|
+
}
|
|
94
|
+
if (senders.size === 0) {
|
|
95
|
+
this.channels.delete(channel);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return affected;
|
|
99
|
+
}
|
|
100
|
+
get(channel) {
|
|
101
|
+
const senders = this.channels.get(channel);
|
|
102
|
+
if (!senders) return [];
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const result = [];
|
|
105
|
+
for (const [sender, entry] of senders) {
|
|
106
|
+
if (entry.expiresAt > now) {
|
|
107
|
+
result.push(sender);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
dispose() {
|
|
113
|
+
clearInterval(this.sweepTimer);
|
|
114
|
+
this.channels.clear();
|
|
115
|
+
if (instance === this) instance = void 0;
|
|
116
|
+
}
|
|
117
|
+
sweep() {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
for (const [channel, senders] of this.channels) {
|
|
120
|
+
for (const [sender, entry] of senders) {
|
|
121
|
+
if (entry.expiresAt <= now) {
|
|
122
|
+
senders.delete(sender);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (senders.size === 0) {
|
|
126
|
+
this.channels.delete(channel);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
var instance;
|
|
132
|
+
function getTypingMap() {
|
|
133
|
+
if (!instance) {
|
|
134
|
+
instance = new TypingMap();
|
|
135
|
+
}
|
|
136
|
+
return instance;
|
|
137
|
+
}
|
|
138
|
+
function isConversationId(channel) {
|
|
139
|
+
return !channel.startsWith("@") && !channel.startsWith("#") && !channel.includes(":") && !channel.includes("/");
|
|
140
|
+
}
|
|
141
|
+
function publishTypingForChannels(channels, map) {
|
|
142
|
+
for (const channel of channels) {
|
|
143
|
+
if (isConversationId(channel)) {
|
|
144
|
+
publish2(channel, { type: "typing", senders: map.get(channel) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// packages/daemon/src/lib/delivery/send-gate.ts
|
|
150
|
+
import { and, asc, desc, eq, gt } from "drizzle-orm";
|
|
151
|
+
var state = /* @__PURE__ */ new Map();
|
|
152
|
+
function extractText(raw) {
|
|
153
|
+
if (!raw) return "";
|
|
154
|
+
try {
|
|
155
|
+
const blocks = JSON.parse(raw);
|
|
156
|
+
if (Array.isArray(blocks)) {
|
|
157
|
+
return blocks.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join(" ").trim();
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
return String(raw);
|
|
162
|
+
}
|
|
163
|
+
async function maxMessageId(conversationId) {
|
|
164
|
+
const db = await getDb();
|
|
165
|
+
const rows = await db.select({ id: messages.id }).from(messages).where(eq(messages.conversation_id, conversationId)).orderBy(desc(messages.id)).limit(1);
|
|
166
|
+
return rows[0]?.id ?? 0;
|
|
167
|
+
}
|
|
168
|
+
async function onDeliveredToMind(mindBase, conversationId) {
|
|
169
|
+
if (!conversationId) return;
|
|
170
|
+
let conv = state.get(mindBase);
|
|
171
|
+
if (!conv) {
|
|
172
|
+
conv = /* @__PURE__ */ new Map();
|
|
173
|
+
state.set(mindBase, conv);
|
|
174
|
+
}
|
|
175
|
+
const existing = conv.get(conversationId);
|
|
176
|
+
if (existing?.turnOpen) return;
|
|
177
|
+
const baselineId = await maxMessageId(conversationId);
|
|
178
|
+
conv.set(conversationId, { baselineId, heldThisTurn: false, turnOpen: true });
|
|
179
|
+
}
|
|
180
|
+
async function checkStaleSend(mindBase, conversationId, excludeSenders) {
|
|
181
|
+
const entry = state.get(mindBase)?.get(conversationId);
|
|
182
|
+
if (!entry?.turnOpen || entry.heldThisTurn) return { held: false };
|
|
183
|
+
const db = await getDb();
|
|
184
|
+
const rows = await db.select({ id: messages.id, sender: messages.sender_name, content: messages.content }).from(messages).where(and(eq(messages.conversation_id, conversationId), gt(messages.id, entry.baselineId))).orderBy(asc(messages.id));
|
|
185
|
+
const excluded = new Set(excludeSenders.filter(Boolean));
|
|
186
|
+
const unseen = rows.filter((r) => r.sender && !excluded.has(r.sender));
|
|
187
|
+
if (unseen.length === 0) return { held: false };
|
|
188
|
+
entry.heldThisTurn = true;
|
|
189
|
+
entry.baselineId = rows[rows.length - 1].id;
|
|
190
|
+
return {
|
|
191
|
+
held: true,
|
|
192
|
+
unseen: unseen.map((r) => ({ sender: r.sender, text: extractText(r.content) }))
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function clearMind(mindBase) {
|
|
196
|
+
state.delete(mindBase);
|
|
197
|
+
}
|
|
198
|
+
function resetTurn(mindBase) {
|
|
199
|
+
clearMind(mindBase);
|
|
200
|
+
}
|
|
201
|
+
function formatHoldNotice(channelLabel, unseen) {
|
|
202
|
+
const lines = unseen.map((m) => {
|
|
203
|
+
const t = m.text.length > 120 ? `${m.text.slice(0, 119)}\u2026` : m.text;
|
|
204
|
+
return ` ${m.sender}: ${JSON.stringify(t)}`;
|
|
205
|
+
});
|
|
206
|
+
const n = unseen.length;
|
|
207
|
+
return [
|
|
208
|
+
`\u26A0 Not sent \u2014 ${n} newer message${n === 1 ? "" : "s"} arrived on ${channelLabel} while you were composing:`,
|
|
209
|
+
...lines,
|
|
210
|
+
"Your message was NOT posted. Revise it in light of these, or run the same send again to post as-is."
|
|
211
|
+
].join("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// packages/daemon/src/lib/delivery/delivery-manager.ts
|
|
215
|
+
var dlog = logger_default.child("delivery-manager");
|
|
216
|
+
var MAX_BATCH_SIZE = 50;
|
|
217
|
+
var REDRIVE_INTERVAL_MS = 15e3;
|
|
218
|
+
var RETRY_BASE_MS = 5e3;
|
|
219
|
+
var RETRY_MAX_MS = 5 * 6e4;
|
|
220
|
+
var REDRIVE_BATCH_LIMIT = 200;
|
|
221
|
+
var MAX_DELIVERY_ATTEMPTS = 10;
|
|
222
|
+
var GATED_RELEASE_LIMIT_PER_CHANNEL = 10;
|
|
223
|
+
var GATED_NOTIFY_EVERY = 10;
|
|
224
|
+
var mentionRegexCache = /* @__PURE__ */ new Map();
|
|
225
|
+
var avatarBlocksCache = /* @__PURE__ */ new Map();
|
|
226
|
+
var AVATAR_CACHE_TTL = 5 * 60 * 1e3;
|
|
227
|
+
var DeliveryManager = class {
|
|
228
|
+
sessionStates = /* @__PURE__ */ new Map();
|
|
229
|
+
batchBuffers = /* @__PURE__ */ new Map();
|
|
230
|
+
/**
|
|
231
|
+
* delivery_queue row ids currently owned in-memory — either buffered in a batch
|
|
232
|
+
* buffer or actively being POSTed. The redrive sweep skips these so it never
|
|
233
|
+
* double-delivers a row that the normal path is already handling.
|
|
234
|
+
*/
|
|
235
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
236
|
+
/**
|
|
237
|
+
* Per-`(baseName:session)` promise chain that serializes POSTs so two rapid
|
|
238
|
+
* messages to the same session can't be reordered by resolvePort/enrichment latency.
|
|
239
|
+
*/
|
|
240
|
+
drainChains = /* @__PURE__ */ new Map();
|
|
241
|
+
redriveTimer = null;
|
|
242
|
+
/** Predicate for whether a mind is up; overridable in tests. */
|
|
243
|
+
isMindRunning = (name) => tryGetMindManager()?.isRunning(name) ?? false;
|
|
244
|
+
/** Delivers a channel event to a mind (invites, release summaries); overridable in tests. */
|
|
245
|
+
notify = async (mindName, text) => {
|
|
246
|
+
const { deliverEvent } = await import("./system-events-ZPSH63QX.js");
|
|
247
|
+
await deliverEvent(mindName, { type: "channel", body: text });
|
|
248
|
+
};
|
|
249
|
+
/** Surfaces a dead-lettered delivery as a next-turn failure notice; overridable in tests. */
|
|
250
|
+
notifyFailure = async (input) => {
|
|
251
|
+
const { recordNotice } = await import("./system-events-ZPSH63QX.js");
|
|
252
|
+
await recordNotice(input);
|
|
253
|
+
};
|
|
254
|
+
constructor() {
|
|
255
|
+
setRoutesChangeListener((mind) => {
|
|
256
|
+
this.releaseGated(mind).catch(
|
|
257
|
+
(err) => dlog.warn(`failed to release gated messages for ${mind}`, logger_default.errorData(err))
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** Test seam: override the mind-running predicate. */
|
|
262
|
+
setRunningCheck(fn) {
|
|
263
|
+
this.isMindRunning = fn;
|
|
264
|
+
}
|
|
265
|
+
/** Test seam: capture/override system notifications sent to minds. */
|
|
266
|
+
setNotifier(fn) {
|
|
267
|
+
this.notify = fn;
|
|
268
|
+
}
|
|
269
|
+
/** Test seam: capture/override dead-letter failure notices. */
|
|
270
|
+
setFailureNotifier(fn) {
|
|
271
|
+
this.notifyFailure = fn;
|
|
272
|
+
}
|
|
273
|
+
// --- Public API ---
|
|
274
|
+
/**
|
|
275
|
+
* Route and deliver a message to a mind. This is the main entry point.
|
|
276
|
+
* The message is routed via the mind's routes.json, then either delivered immediately
|
|
277
|
+
* or queued for batching depending on the session's delivery mode.
|
|
278
|
+
*/
|
|
279
|
+
async routeAndDeliver(mindName, payload) {
|
|
280
|
+
const baseName = await getBaseName(mindName);
|
|
281
|
+
const config = getRoutingConfig(baseName);
|
|
282
|
+
if (payload.session) {
|
|
283
|
+
let sessionName2 = payload.session;
|
|
284
|
+
if (sessionName2 === "$new") {
|
|
285
|
+
sessionName2 = newEphemeralSession();
|
|
286
|
+
}
|
|
287
|
+
const sessionConfig2 = resolveDeliveryMode(config, sessionName2);
|
|
288
|
+
if (sessionConfig2.delivery.mode === "batch") {
|
|
289
|
+
await this.enqueueBatch(mindName, sessionName2, payload, sessionConfig2);
|
|
290
|
+
return { routed: true, session: sessionName2, destination: "mind", mode: "batch" };
|
|
291
|
+
}
|
|
292
|
+
const queueId2 = await this.persistToQueue(mindName, sessionName2, payload);
|
|
293
|
+
await this.deliverToMind(mindName, sessionName2, payload, sessionConfig2, queueId2);
|
|
294
|
+
return { routed: true, session: sessionName2, destination: "mind", mode: "immediate" };
|
|
295
|
+
}
|
|
296
|
+
const meta = {
|
|
297
|
+
channel: payload.channel,
|
|
298
|
+
sender: payload.sender ?? void 0,
|
|
299
|
+
isDM: payload.isDM,
|
|
300
|
+
participantCount: payload.participantCount
|
|
301
|
+
};
|
|
302
|
+
const route = resolveRoute(config, meta);
|
|
303
|
+
dlog.debug(
|
|
304
|
+
`route for ${mindName} ch=${payload.channel}: dest=${route.destination} matched=${route.matched}`
|
|
305
|
+
);
|
|
306
|
+
if (route.destination === "file") {
|
|
307
|
+
return { routed: true, session: route.path, destination: "file", mode: "immediate" };
|
|
308
|
+
}
|
|
309
|
+
if (shouldGate(config, route)) {
|
|
310
|
+
dlog.debug(`gating unmatched channel ${payload.channel} for ${mindName}`);
|
|
311
|
+
await this.gateMessage(mindName, route.session, payload);
|
|
312
|
+
return { routed: true, session: route.session, destination: "mind", mode: "gated" };
|
|
313
|
+
}
|
|
314
|
+
if (route.mode === "mention" && payload.sender) {
|
|
315
|
+
const text = extractTextContent(payload.content);
|
|
316
|
+
let pattern = mentionRegexCache.get(baseName);
|
|
317
|
+
if (!pattern) {
|
|
318
|
+
const escaped = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
319
|
+
pattern = new RegExp(`\\b${escaped}\\b`, "i");
|
|
320
|
+
mentionRegexCache.set(baseName, pattern);
|
|
321
|
+
}
|
|
322
|
+
if (!pattern.test(text)) {
|
|
323
|
+
dlog.debug(`mention-filtered message on ${payload.channel} for ${mindName}`);
|
|
324
|
+
return { routed: false, reason: "mention-filtered" };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
let sessionName = route.session;
|
|
328
|
+
if (sessionName === "$new") {
|
|
329
|
+
sessionName = newEphemeralSession();
|
|
330
|
+
}
|
|
331
|
+
const sessionConfig = resolveDeliveryMode(config, sessionName, route.rule);
|
|
332
|
+
if (sessionConfig.delivery.mode === "batch") {
|
|
333
|
+
dlog.debug(`enqueueing batch message for ${mindName}/${sessionName}`);
|
|
334
|
+
await this.enqueueBatch(mindName, sessionName, payload, sessionConfig);
|
|
335
|
+
return { routed: true, session: sessionName, destination: "mind", mode: "batch" };
|
|
336
|
+
}
|
|
337
|
+
const queueId = await this.persistToQueue(mindName, sessionName, payload);
|
|
338
|
+
await this.deliverToMind(mindName, sessionName, payload, sessionConfig, queueId);
|
|
339
|
+
return { routed: true, session: sessionName, destination: "mind", mode: "immediate" };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Called when a mind's session emits a "done" event — decrements active count
|
|
343
|
+
* and may trigger batch flush if session goes idle.
|
|
344
|
+
*
|
|
345
|
+
* This method is intentionally synchronous to avoid race conditions: the caller
|
|
346
|
+
* has already resolved baseName, and any async yield here (e.g. getBaseName)
|
|
347
|
+
* would allow concurrent deliveries to incrementActive before the decrement runs,
|
|
348
|
+
* causing isSessionBusy to return true even when no deliveries are pending.
|
|
349
|
+
*/
|
|
350
|
+
sessionDone(baseName, session) {
|
|
351
|
+
resetTurn(baseName);
|
|
352
|
+
if (session) {
|
|
353
|
+
this.decrementActive(baseName, session);
|
|
354
|
+
} else {
|
|
355
|
+
const mindSessions = this.sessionStates.get(baseName);
|
|
356
|
+
if (mindSessions) {
|
|
357
|
+
for (const [sessionName] of mindSessions) {
|
|
358
|
+
this.decrementActive(baseName, sessionName);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Restore queued messages from DB on daemon restart — a single redrive pass.
|
|
365
|
+
* All accepted deliveries are persisted to delivery_queue before their POST and
|
|
366
|
+
* only deleted on mind-ack, so pending rows are exactly the undelivered messages.
|
|
367
|
+
*/
|
|
368
|
+
async restoreFromDb() {
|
|
369
|
+
await this.redrive();
|
|
370
|
+
}
|
|
371
|
+
/** Start the periodic redrive sweep (idempotent). */
|
|
372
|
+
startRedrive() {
|
|
373
|
+
if (this.redriveTimer) return;
|
|
374
|
+
this.redriveTimer = setInterval(() => {
|
|
375
|
+
this.redrive().catch((err) => dlog.warn("redrive sweep failed", logger_default.errorData(err)));
|
|
376
|
+
}, REDRIVE_INTERVAL_MS);
|
|
377
|
+
this.redriveTimer.unref();
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Re-read pending delivery_queue rows that are eligible (past their backoff window)
|
|
381
|
+
* and re-deliver them through the normal path. Rows already owned in-memory
|
|
382
|
+
* (`inFlight`) and minds that are down are skipped so we never hot-loop or double-send.
|
|
383
|
+
*/
|
|
384
|
+
async redrive() {
|
|
385
|
+
let rows;
|
|
386
|
+
try {
|
|
387
|
+
const db = await getDb();
|
|
388
|
+
rows = await db.select().from(deliveryQueue).where(
|
|
389
|
+
and2(
|
|
390
|
+
eq2(deliveryQueue.status, "pending"),
|
|
391
|
+
sql`(${deliveryQueue.next_attempt_at} IS NULL OR ${deliveryQueue.next_attempt_at} <= datetime('now'))`
|
|
392
|
+
)
|
|
393
|
+
).orderBy(deliveryQueue.id).limit(REDRIVE_BATCH_LIMIT);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
dlog.warn("failed to read delivery queue for redrive", logger_default.errorData(err));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
let redriven = 0;
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
if (this.inFlight.has(row.id)) continue;
|
|
401
|
+
if (!this.isMindRunning(row.mind)) continue;
|
|
402
|
+
let payload;
|
|
403
|
+
try {
|
|
404
|
+
payload = JSON.parse(row.payload);
|
|
405
|
+
} catch (parseErr) {
|
|
406
|
+
dlog.warn(
|
|
407
|
+
`corrupt payload in delivery queue row ${row.id}, dropping`,
|
|
408
|
+
logger_default.errorData(parseErr)
|
|
409
|
+
);
|
|
410
|
+
await this.deleteQueueRows([row.id]);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const config = getRoutingConfig(row.mind);
|
|
414
|
+
const sessionConfig = resolveDeliveryMode(config, row.thread);
|
|
415
|
+
const target = row.target_mind ?? row.mind;
|
|
416
|
+
if (sessionConfig.delivery.mode === "batch") {
|
|
417
|
+
this.inFlight.add(row.id);
|
|
418
|
+
this.addToBatchBuffer(target, row.thread, sessionConfig, {
|
|
419
|
+
payload,
|
|
420
|
+
channel: payload.channel,
|
|
421
|
+
sender: payload.sender ?? null,
|
|
422
|
+
createdAt: Date.now(),
|
|
423
|
+
queueId: row.id
|
|
424
|
+
});
|
|
425
|
+
} else {
|
|
426
|
+
this.deliverToMind(target, row.thread, payload, sessionConfig, row.id).catch((err) => {
|
|
427
|
+
dlog.warn(`failed to redrive delivery for ${target}`, logger_default.errorData(err));
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
redriven++;
|
|
431
|
+
}
|
|
432
|
+
if (redriven > 0) dlog.info(`redrove ${redriven} pending delivery queue rows`);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Re-evaluate a mind's `gated` rows against its current routes.json when the routing
|
|
436
|
+
* config changes. For each channel that now matches a `mind` route:
|
|
437
|
+
* - the newest {@link GATED_RELEASE_LIMIT_PER_CHANNEL} rows are promoted to `pending`
|
|
438
|
+
* and re-stamped with the freshly-resolved session (NOT the gate-time fallback), so
|
|
439
|
+
* they land in the correct session instead of `main` (#537 bug 1);
|
|
440
|
+
* - any older rows are `archived` (inert) so a long backlog can't flood the mind
|
|
441
|
+
* in one sweep (#537 bug 2), and the mind gets one summary rather than a flood.
|
|
442
|
+
* Rows resolving to a `file` destination are archived (the delivery manager doesn't
|
|
443
|
+
* deliver file routes). Declined channels are skipped entirely — they stay gated.
|
|
444
|
+
*/
|
|
445
|
+
async releaseGated(mindName) {
|
|
446
|
+
const baseName = await getBaseName(mindName);
|
|
447
|
+
const config = getRoutingConfig(baseName);
|
|
448
|
+
let rows;
|
|
449
|
+
try {
|
|
450
|
+
const db = await getDb();
|
|
451
|
+
rows = await db.select().from(deliveryQueue).where(and2(eq2(deliveryQueue.mind, baseName), eq2(deliveryQueue.status, "gated")));
|
|
452
|
+
} catch (err) {
|
|
453
|
+
dlog.warn(`failed to read gated rows for ${baseName}`, logger_default.errorData(err));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const byChannel = /* @__PURE__ */ new Map();
|
|
457
|
+
const archiveIds = [];
|
|
458
|
+
for (const row of rows) {
|
|
459
|
+
let payload;
|
|
460
|
+
try {
|
|
461
|
+
payload = JSON.parse(row.payload);
|
|
462
|
+
} catch {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const meta = {
|
|
466
|
+
channel: payload.channel,
|
|
467
|
+
sender: payload.sender ?? void 0,
|
|
468
|
+
isDM: payload.isDM,
|
|
469
|
+
participantCount: payload.participantCount
|
|
470
|
+
};
|
|
471
|
+
const route = resolveRoute(config, meta);
|
|
472
|
+
if (!route.matched) continue;
|
|
473
|
+
if (route.destination === "file") {
|
|
474
|
+
archiveIds.push(row.id);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
let session = route.session;
|
|
478
|
+
if (session === "$new") {
|
|
479
|
+
session = newEphemeralSession();
|
|
480
|
+
}
|
|
481
|
+
const channel = row.channel ?? payload.channel ?? "unknown";
|
|
482
|
+
const list = byChannel.get(channel) ?? [];
|
|
483
|
+
list.push({
|
|
484
|
+
id: row.id,
|
|
485
|
+
session,
|
|
486
|
+
channel,
|
|
487
|
+
sender: payload.sender ?? row.sender ?? null,
|
|
488
|
+
content: extractTextContent(payload.content)
|
|
489
|
+
});
|
|
490
|
+
byChannel.set(channel, list);
|
|
491
|
+
}
|
|
492
|
+
const promote = [];
|
|
493
|
+
const truncationNotes = [];
|
|
494
|
+
for (const [channel, items] of byChannel) {
|
|
495
|
+
if (await this.isChannelDeclined(baseName, channel)) continue;
|
|
496
|
+
items.sort((a, b) => b.id - a.id);
|
|
497
|
+
const keep = items.slice(0, GATED_RELEASE_LIMIT_PER_CHANNEL);
|
|
498
|
+
const drop = items.slice(GATED_RELEASE_LIMIT_PER_CHANNEL);
|
|
499
|
+
promote.push(...keep);
|
|
500
|
+
for (const d of drop) archiveIds.push(d.id);
|
|
501
|
+
if (drop.length > 0) {
|
|
502
|
+
truncationNotes.push(
|
|
503
|
+
`${channel}: released the ${keep.length} most recent message(s); ${drop.length} earlier message(s) were held while unrouted and remain in the channel history (volute chat read ${channel}).`
|
|
504
|
+
);
|
|
505
|
+
dlog.info(
|
|
506
|
+
`truncated gated release for ${baseName} on ${channel}: kept ${keep.length}, archived ${drop.length}`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (archiveIds.length > 0) {
|
|
511
|
+
try {
|
|
512
|
+
const db = await getDb();
|
|
513
|
+
for (let i = 0; i < archiveIds.length; i += 500) {
|
|
514
|
+
await db.update(deliveryQueue).set({ status: "archived" }).where(inArray(deliveryQueue.id, archiveIds.slice(i, i + 500)));
|
|
515
|
+
}
|
|
516
|
+
} catch (err) {
|
|
517
|
+
dlog.warn(`failed to archive gated rows for ${baseName}`, logger_default.errorData(err));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (promote.length === 0) {
|
|
521
|
+
if (truncationNotes.length > 0) await this.sendReleaseSummary(mindName, truncationNotes);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const orderedPromote = [...promote].sort((a, b) => a.id - b.id);
|
|
525
|
+
try {
|
|
526
|
+
const db = await getDb();
|
|
527
|
+
for (const p of orderedPromote) {
|
|
528
|
+
await db.transaction(async (tx) => {
|
|
529
|
+
await tx.insert(mindHistory).values({
|
|
530
|
+
mind: baseName,
|
|
531
|
+
type: "inbound",
|
|
532
|
+
channel: p.channel,
|
|
533
|
+
sender: p.sender,
|
|
534
|
+
content: p.content
|
|
535
|
+
});
|
|
536
|
+
await tx.update(deliveryQueue).set({ status: "pending", thread: p.session, attempts: 0, next_attempt_at: null }).where(eq2(deliveryQueue.id, p.id));
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
dlog.info(`released ${promote.length} gated message(s) for ${baseName} after route change`);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
dlog.error(
|
|
542
|
+
`failed to record+promote gated rows for ${baseName} (channels: ${[...byChannel.keys()].join(", ")})`,
|
|
543
|
+
logger_default.errorData(err)
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
for (const p of orderedPromote) {
|
|
548
|
+
publish(baseName, {
|
|
549
|
+
mind: baseName,
|
|
550
|
+
type: "inbound",
|
|
551
|
+
channel: p.channel,
|
|
552
|
+
content: p.content ?? void 0,
|
|
553
|
+
sender: p.sender ?? void 0
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
if (truncationNotes.length > 0) await this.sendReleaseSummary(mindName, truncationNotes);
|
|
557
|
+
await this.redrive();
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Whether the mind has explicitly declined a channel. A declined channel keeps
|
|
561
|
+
* persisting history but never notifies and is never released. #537
|
|
562
|
+
*/
|
|
563
|
+
async isChannelDeclined(baseName, channel) {
|
|
564
|
+
if (!channel) return false;
|
|
565
|
+
try {
|
|
566
|
+
const db = await getDb();
|
|
567
|
+
const rows = await db.select({ state: channelGates.state }).from(channelGates).where(and2(eq2(channelGates.mind, baseName), eq2(channelGates.channel, channel)));
|
|
568
|
+
return rows[0]?.state === "declined";
|
|
569
|
+
} catch (err) {
|
|
570
|
+
dlog.warn(`failed to read gate state for ${baseName}/${channel}`, logger_default.errorData(err));
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Record that a mind has declined an unrouted channel: future messages are still
|
|
576
|
+
* persisted (history is preserved) but never notify, and any currently-gated rows are
|
|
577
|
+
* archived so they're inert. Returns the number of held messages archived. #537
|
|
578
|
+
*/
|
|
579
|
+
async declineChannel(mindName, channel) {
|
|
580
|
+
const baseName = await getBaseName(mindName);
|
|
581
|
+
const db = await getDb();
|
|
582
|
+
await db.insert(channelGates).values({ mind: baseName, channel, state: "declined" }).onConflictDoUpdate({
|
|
583
|
+
target: [channelGates.mind, channelGates.channel],
|
|
584
|
+
set: { state: "declined", updated_at: sql`(datetime('now'))` }
|
|
585
|
+
});
|
|
586
|
+
const archived = await db.update(deliveryQueue).set({ status: "archived" }).where(
|
|
587
|
+
and2(
|
|
588
|
+
eq2(deliveryQueue.mind, baseName),
|
|
589
|
+
eq2(deliveryQueue.channel, channel),
|
|
590
|
+
eq2(deliveryQueue.status, "gated")
|
|
591
|
+
)
|
|
592
|
+
).returning({ id: deliveryQueue.id });
|
|
593
|
+
dlog.info(
|
|
594
|
+
`declined channel ${channel} for ${baseName}; archived ${archived.length} held row(s)`
|
|
595
|
+
);
|
|
596
|
+
return archived.length;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Send the mind a single summary when a routing change released a truncated backlog,
|
|
600
|
+
* rather than a flood of individual messages. #537
|
|
601
|
+
*/
|
|
602
|
+
async sendReleaseSummary(mindName, notes) {
|
|
603
|
+
const body = [
|
|
604
|
+
`[Channel backlog released]`,
|
|
605
|
+
`A routing change matched channel(s) that had held messages while unrouted. To avoid flooding you, only the ${GATED_RELEASE_LIMIT_PER_CHANNEL} most recent per channel were delivered:`,
|
|
606
|
+
"",
|
|
607
|
+
...notes.map((n) => `- ${n}`)
|
|
608
|
+
].join("\n");
|
|
609
|
+
try {
|
|
610
|
+
await this.notify(mindName, body);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
dlog.warn(`failed to send release summary for ${mindName}`, logger_default.errorData(err));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Get pending (gated) messages for a mind.
|
|
617
|
+
*/
|
|
618
|
+
async getPending(mindName) {
|
|
619
|
+
const db = await getDb();
|
|
620
|
+
const rows = await db.select().from(deliveryQueue).where(and2(eq2(deliveryQueue.mind, mindName), eq2(deliveryQueue.status, "gated")));
|
|
621
|
+
const byChannel = /* @__PURE__ */ new Map();
|
|
622
|
+
for (const row of rows) {
|
|
623
|
+
const ch = row.channel ?? "unknown";
|
|
624
|
+
const existing = byChannel.get(ch) ?? [];
|
|
625
|
+
existing.push(row);
|
|
626
|
+
byChannel.set(ch, existing);
|
|
627
|
+
}
|
|
628
|
+
return [...byChannel.entries()].map(([channel, channelRows]) => {
|
|
629
|
+
const firstRow = channelRows[0];
|
|
630
|
+
const payload = JSON.parse(firstRow.payload);
|
|
631
|
+
const text = extractTextContent(payload.content);
|
|
632
|
+
return {
|
|
633
|
+
channel,
|
|
634
|
+
sender: firstRow.sender,
|
|
635
|
+
count: channelRows.length,
|
|
636
|
+
firstSeen: firstRow.created_at,
|
|
637
|
+
preview: text.length > 200 ? `${text.slice(0, 200)}...` : text
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Check if a session is currently busy (has active deliveries).
|
|
643
|
+
*/
|
|
644
|
+
isSessionBusy(mindName, session) {
|
|
645
|
+
const state2 = this.sessionStates.get(mindName)?.get(session);
|
|
646
|
+
return (state2?.activeCount ?? 0) > 0;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Check if any session for a mind is currently busy.
|
|
650
|
+
*/
|
|
651
|
+
isMindBusy(mindName) {
|
|
652
|
+
const mindSessions = this.sessionStates.get(mindName);
|
|
653
|
+
if (!mindSessions) return false;
|
|
654
|
+
for (const [, state2] of mindSessions) {
|
|
655
|
+
if (state2.activeCount > 0) return true;
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Clear all session state for a specific mind (called on mind stop/crash).
|
|
661
|
+
* Resets active counts, clears typing indicators, and cleans up batch buffers
|
|
662
|
+
* so ghost state doesn't accumulate.
|
|
663
|
+
*/
|
|
664
|
+
clearMindSessions(mindName) {
|
|
665
|
+
this.sessionStates.delete(mindName);
|
|
666
|
+
clearMind(mindName);
|
|
667
|
+
const typingMap = getTypingMap();
|
|
668
|
+
publishTypingForChannels(typingMap.deleteSender(mindName), typingMap);
|
|
669
|
+
const toDelete = [];
|
|
670
|
+
for (const [bufferKey, buffer] of this.batchBuffers) {
|
|
671
|
+
if (bufferKey.startsWith(`${mindName}:`)) {
|
|
672
|
+
if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
|
|
673
|
+
if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
|
|
674
|
+
for (const msg of buffer.messages) {
|
|
675
|
+
if (msg.queueId != null) this.inFlight.delete(msg.queueId);
|
|
676
|
+
}
|
|
677
|
+
toDelete.push(bufferKey);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
for (const k of toDelete) this.batchBuffers.delete(k);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Reset a single session's leaked active count back to zero.
|
|
684
|
+
*
|
|
685
|
+
* Used by the wedged-turn sweep: when a session's `activeCount` drifts above zero
|
|
686
|
+
* (deliveries outnumbering `done`s) it gates turn completion indefinitely. Once the sweep
|
|
687
|
+
* confirms the session is genuinely idle, this clears the stale count so the next turn
|
|
688
|
+
* can complete normally. Batch buffers are left intact — their own maxWait timer flushes
|
|
689
|
+
* any pending messages.
|
|
690
|
+
*
|
|
691
|
+
* `minIdleMs` guards against a race: if a delivery landed within that window, a fresh turn
|
|
692
|
+
* may legitimately be in flight (real `activeCount`), so zeroing would complete it early.
|
|
693
|
+
* In that case we skip — the next sweep retries if it's still wedged. Returns whether the
|
|
694
|
+
* count was reset.
|
|
695
|
+
*/
|
|
696
|
+
clearSessionActive(mindName, session, minIdleMs) {
|
|
697
|
+
const state2 = this.sessionStates.get(mindName)?.get(session);
|
|
698
|
+
if (!state2) return false;
|
|
699
|
+
if (Date.now() - state2.lastDeliveredAt < minIdleMs) return false;
|
|
700
|
+
state2.activeCount = 0;
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Cleanup all timers and subscriptions.
|
|
705
|
+
*/
|
|
706
|
+
dispose() {
|
|
707
|
+
for (const [, buffer] of this.batchBuffers) {
|
|
708
|
+
if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
|
|
709
|
+
if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
|
|
710
|
+
}
|
|
711
|
+
this.batchBuffers.clear();
|
|
712
|
+
this.sessionStates.clear();
|
|
713
|
+
this.inFlight.clear();
|
|
714
|
+
this.drainChains.clear();
|
|
715
|
+
if (this.redriveTimer) {
|
|
716
|
+
clearInterval(this.redriveTimer);
|
|
717
|
+
this.redriveTimer = null;
|
|
718
|
+
}
|
|
719
|
+
setRoutesChangeListener(void 0);
|
|
720
|
+
if (instance2 === this) instance2 = void 0;
|
|
721
|
+
}
|
|
722
|
+
// --- Private ---
|
|
723
|
+
async resolvePort(mindName) {
|
|
724
|
+
const entry = await findMind(mindName);
|
|
725
|
+
if (!entry) return null;
|
|
726
|
+
const baseName = entry.parent ?? mindName;
|
|
727
|
+
return { baseName, port: entry.port };
|
|
728
|
+
}
|
|
729
|
+
async postToMind(port, body) {
|
|
730
|
+
const controller = new AbortController();
|
|
731
|
+
const timeout = setTimeout(() => controller.abort(), 12e4);
|
|
732
|
+
try {
|
|
733
|
+
const res = await fetch(`http://127.0.0.1:${port}/message`, {
|
|
734
|
+
method: "POST",
|
|
735
|
+
headers: { "Content-Type": "application/json" },
|
|
736
|
+
body,
|
|
737
|
+
signal: controller.signal
|
|
738
|
+
});
|
|
739
|
+
if (!res.ok) {
|
|
740
|
+
const text = await res.text().catch(() => "");
|
|
741
|
+
dlog.warn(`mind responded ${res.status}: ${text}`);
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
await res.text().catch(() => {
|
|
745
|
+
});
|
|
746
|
+
return true;
|
|
747
|
+
} finally {
|
|
748
|
+
clearTimeout(timeout);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Serialize `fn` on a per-key promise chain so calls for the same key run one at a
|
|
753
|
+
* time in submission order. Used to drain a `(mind, session)` sequentially.
|
|
754
|
+
*/
|
|
755
|
+
runSequential(key, fn) {
|
|
756
|
+
const prev = this.drainChains.get(key) ?? Promise.resolve();
|
|
757
|
+
const run = prev.then(fn, fn);
|
|
758
|
+
const tail = run.then(
|
|
759
|
+
() => {
|
|
760
|
+
},
|
|
761
|
+
() => {
|
|
762
|
+
}
|
|
763
|
+
);
|
|
764
|
+
this.drainChains.set(key, tail);
|
|
765
|
+
tail.then(() => {
|
|
766
|
+
if (this.drainChains.get(key) === tail) this.drainChains.delete(key);
|
|
767
|
+
});
|
|
768
|
+
return run;
|
|
769
|
+
}
|
|
770
|
+
/** Delete delivered queue rows by their specific ids. */
|
|
771
|
+
async deleteQueueRows(ids) {
|
|
772
|
+
const valid = [...new Set(ids.filter((id) => typeof id === "number"))];
|
|
773
|
+
if (valid.length === 0) return;
|
|
774
|
+
try {
|
|
775
|
+
const db = await getDb();
|
|
776
|
+
await db.delete(deliveryQueue).where(inArray(deliveryQueue.id, valid));
|
|
777
|
+
} catch (err) {
|
|
778
|
+
dlog.warn("failed to delete delivered delivery queue rows", logger_default.errorData(err));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Record the outcome of a failed delivery attempt: set a backoff window so the target
|
|
783
|
+
* isn't hot-looped, and — for a LIVE rejection — advance the dead-letter counter.
|
|
784
|
+
*
|
|
785
|
+
* Only a live rejection (`liveRejection: true`: the target was reachable and answered with
|
|
786
|
+
* a non-OK HTTP status) counts toward {@link MAX_DELIVERY_ATTEMPTS}. A transport failure
|
|
787
|
+
* (`liveRejection: false`: connection refused, reset, or timeout — the mind or a detached
|
|
788
|
+
* variant is simply down/unreachable) backs off WITHOUT advancing the counter, so a merely
|
|
789
|
+
* offline target is never dead-lettered and its message is preserved until it returns. The
|
|
790
|
+
* redrive guard only checks the base mind's up-ness, so a stopped variant reaches here on
|
|
791
|
+
* every sweep — this is what stops that from silently dropping its messages. #356
|
|
792
|
+
*
|
|
793
|
+
* A row that reaches the ceiling moves to the terminal `dead` status (excluded from
|
|
794
|
+
* redrive, which only reads `pending`) and the batch is surfaced as one failure notice.
|
|
795
|
+
*
|
|
796
|
+
* Rows here are still `inFlight` (the caller clears ownership in its own `finally`, after
|
|
797
|
+
* this resolves), so the concurrent redrive sweep skips them and can't race this update.
|
|
798
|
+
*/
|
|
799
|
+
async scheduleRetry(ids, opts) {
|
|
800
|
+
const valid = [...new Set(ids.filter((id) => typeof id === "number"))];
|
|
801
|
+
if (valid.length === 0) return;
|
|
802
|
+
try {
|
|
803
|
+
const db = await getDb();
|
|
804
|
+
const rows = await db.select({
|
|
805
|
+
id: deliveryQueue.id,
|
|
806
|
+
attempts: deliveryQueue.attempts,
|
|
807
|
+
mind: deliveryQueue.mind,
|
|
808
|
+
target_mind: deliveryQueue.target_mind,
|
|
809
|
+
thread: deliveryQueue.thread,
|
|
810
|
+
channel: deliveryQueue.channel,
|
|
811
|
+
sender: deliveryQueue.sender,
|
|
812
|
+
created_at: deliveryQueue.created_at
|
|
813
|
+
}).from(deliveryQueue).where(inArray(deliveryQueue.id, valid));
|
|
814
|
+
const dead = [];
|
|
815
|
+
for (const row of rows) {
|
|
816
|
+
if (!opts.liveRejection) {
|
|
817
|
+
await db.update(deliveryQueue).set({ next_attempt_at: this.backoffExpr(row.attempts) }).where(eq2(deliveryQueue.id, row.id));
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const attempts = row.attempts + 1;
|
|
821
|
+
if (attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
822
|
+
dlog.error(
|
|
823
|
+
`dead-lettering delivery queue row ${row.id} for ${row.mind} after ${attempts} live rejections (channel=${row.channel ?? "?"}, sender=${row.sender ?? "?"})`
|
|
824
|
+
);
|
|
825
|
+
const flipped = await db.update(deliveryQueue).set({ attempts, status: "dead", next_attempt_at: null }).where(and2(eq2(deliveryQueue.id, row.id), eq2(deliveryQueue.status, "pending"))).returning({ id: deliveryQueue.id });
|
|
826
|
+
if (flipped.length > 0) dead.push(row);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
await db.update(deliveryQueue).set({ attempts, next_attempt_at: this.backoffExpr(attempts) }).where(eq2(deliveryQueue.id, row.id));
|
|
830
|
+
}
|
|
831
|
+
if (dead.length > 0) await this.notifyDeadLettered(dead);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
dlog.error("failed to record delivery retry / dead-letter", logger_default.errorData(err));
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/** Exponential backoff window (capped at {@link RETRY_MAX_MS}) as a SQL datetime expr. */
|
|
837
|
+
backoffExpr(attempts) {
|
|
838
|
+
const backoffSec = Math.round(
|
|
839
|
+
Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** Math.min(attempts, 20)) / 1e3
|
|
840
|
+
);
|
|
841
|
+
return sql`datetime('now', ${`+${backoffSec} seconds`})`;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Surface a batch of dead-lettered rows as ONE next-turn failure notice, so a whole failing
|
|
845
|
+
* batch can't emit dozens of notices and evict unrelated ones via the per-thread overflow
|
|
846
|
+
* cap. The notice names the channel(s) and send times and points the mind at the surviving
|
|
847
|
+
* channel history, and never throws back into the delivery path — the rows are terminal. #356
|
|
848
|
+
*/
|
|
849
|
+
async notifyDeadLettered(rows) {
|
|
850
|
+
const mind = rows[0].mind;
|
|
851
|
+
const threads = new Set(rows.map((r) => r.thread));
|
|
852
|
+
const single = threads.size === 1 ? [...threads][0] : MIND_LEVEL_THREAD;
|
|
853
|
+
const thread = single.startsWith("new-") ? MIND_LEVEL_THREAD : single;
|
|
854
|
+
const lines = rows.map((r) => {
|
|
855
|
+
const from = r.sender ? ` from ${r.sender}` : "";
|
|
856
|
+
const on = r.channel ? ` on ${r.channel}` : "";
|
|
857
|
+
const to = r.target_mind && r.target_mind !== r.mind ? ` to your variant ${r.target_mind}` : "";
|
|
858
|
+
return `- a message${from}${on}${to} (sent ${r.created_at})`;
|
|
859
|
+
});
|
|
860
|
+
const channels = [...new Set(rows.map((r) => r.channel).filter((c) => !!c))];
|
|
861
|
+
const recovery = channels.length > 0 ? `The original message(s) remain in the channel history \u2014 read them with ${channels.map((c) => `\`volute chat read ${c}\``).join(" or ")}.` : `The original message(s) remain in the channel history.`;
|
|
862
|
+
const detail = [
|
|
863
|
+
`${rows.length} message(s) sent to you could not be delivered after ${MAX_DELIVERY_ATTEMPTS} attempts and were dropped:`,
|
|
864
|
+
...lines,
|
|
865
|
+
recovery
|
|
866
|
+
].join("\n");
|
|
867
|
+
try {
|
|
868
|
+
await this.notifyFailure({
|
|
869
|
+
mind,
|
|
870
|
+
thread,
|
|
871
|
+
kind: "delivery_failed",
|
|
872
|
+
reason: "delivery_failed",
|
|
873
|
+
detail
|
|
874
|
+
});
|
|
875
|
+
} catch (err) {
|
|
876
|
+
dlog.warn(`failed to send dead-letter notice for ${mind}`, logger_default.errorData(err));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
async deliverToMind(mindName, session, payload, sessionConfig, queueId) {
|
|
880
|
+
if (queueId != null) this.inFlight.add(queueId);
|
|
881
|
+
await this.runSequential(`${mindName}:${session}`, async () => {
|
|
882
|
+
const resolved = await this.resolvePort(mindName);
|
|
883
|
+
if (!resolved) {
|
|
884
|
+
dlog.warn(`cannot deliver to ${mindName}: mind not found`);
|
|
885
|
+
if (queueId != null) this.inFlight.delete(queueId);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const { baseName, port } = resolved;
|
|
889
|
+
const senders = /* @__PURE__ */ new Set();
|
|
890
|
+
if (payload.sender) senders.add(payload.sender);
|
|
891
|
+
const channels = /* @__PURE__ */ new Set();
|
|
892
|
+
if (payload.channel) channels.add(payload.channel);
|
|
893
|
+
this.incrementActive(baseName, session, senders, channels);
|
|
894
|
+
await onDeliveredToMind(baseName, payload.conversationId);
|
|
895
|
+
linkInboundToActiveTurn(baseName, session, payload.channel).catch(
|
|
896
|
+
(err) => dlog.warn(`failed to link mid-turn inbound for ${baseName}`, logger_default.errorData(err))
|
|
897
|
+
);
|
|
898
|
+
const typingMap = getTypingMap();
|
|
899
|
+
if (payload.channel) {
|
|
900
|
+
typingMap.set(payload.channel, baseName, { persistent: true });
|
|
901
|
+
}
|
|
902
|
+
if (payload.conversationId) {
|
|
903
|
+
typingMap.set(payload.conversationId, baseName, { persistent: true });
|
|
904
|
+
publishTypingForChannels([payload.conversationId], typingMap);
|
|
905
|
+
}
|
|
906
|
+
onMindEvent(baseName, "delivery", payload.channel);
|
|
907
|
+
const enrichedPayload = await this.enrichWithProfiles(baseName, session, payload);
|
|
908
|
+
const body = JSON.stringify({
|
|
909
|
+
...enrichedPayload,
|
|
910
|
+
session,
|
|
911
|
+
instructions: sessionConfig.instructions
|
|
912
|
+
});
|
|
913
|
+
try {
|
|
914
|
+
const ok = await this.postToMind(port, body);
|
|
915
|
+
if (!ok) {
|
|
916
|
+
this.decrementActive(baseName, session);
|
|
917
|
+
publishTypingForChannels(typingMap.deleteSender(baseName), typingMap);
|
|
918
|
+
await this.scheduleRetry([queueId], { liveRejection: true });
|
|
919
|
+
} else {
|
|
920
|
+
await this.deleteQueueRows([queueId]);
|
|
921
|
+
}
|
|
922
|
+
} catch (err) {
|
|
923
|
+
dlog.warn(`failed to deliver to ${mindName}`, logger_default.errorData(err));
|
|
924
|
+
this.decrementActive(baseName, session);
|
|
925
|
+
publishTypingForChannels(typingMap.deleteSender(baseName), typingMap);
|
|
926
|
+
await this.scheduleRetry([queueId], { liveRejection: false });
|
|
927
|
+
} finally {
|
|
928
|
+
if (queueId != null) this.inFlight.delete(queueId);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
async deliverBatchToMind(mindName, session, messages2, sessionConfig) {
|
|
933
|
+
const queueIds = messages2.map((m) => m.queueId).filter((id) => typeof id === "number");
|
|
934
|
+
await this.runSequential(`${mindName}:${session}`, async () => {
|
|
935
|
+
const resolved = await this.resolvePort(mindName);
|
|
936
|
+
if (!resolved) {
|
|
937
|
+
dlog.warn(`cannot deliver batch to ${mindName}: mind not found`);
|
|
938
|
+
for (const id of queueIds) this.inFlight.delete(id);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const { baseName, port } = resolved;
|
|
942
|
+
const firstPerChannel = /* @__PURE__ */ new Set();
|
|
943
|
+
const isFirstForChannel = [];
|
|
944
|
+
for (const msg of messages2) {
|
|
945
|
+
const ch = msg.channel ?? "unknown";
|
|
946
|
+
isFirstForChannel.push(!firstPerChannel.has(ch));
|
|
947
|
+
firstPerChannel.add(ch);
|
|
948
|
+
}
|
|
949
|
+
const enrichedMessages = await Promise.all(
|
|
950
|
+
messages2.map(async (msg, i) => {
|
|
951
|
+
if (!isFirstForChannel[i]) return msg;
|
|
952
|
+
const enrichedPayload = await this.enrichWithProfiles(baseName, session, msg.payload);
|
|
953
|
+
return { ...msg, payload: enrichedPayload };
|
|
954
|
+
})
|
|
955
|
+
);
|
|
956
|
+
const channels = {};
|
|
957
|
+
for (const msg of enrichedMessages) {
|
|
958
|
+
const ch = msg.channel ?? "unknown";
|
|
959
|
+
if (!channels[ch]) channels[ch] = [];
|
|
960
|
+
channels[ch].push(msg.payload);
|
|
961
|
+
}
|
|
962
|
+
const senders = /* @__PURE__ */ new Set();
|
|
963
|
+
const channelSet = /* @__PURE__ */ new Set();
|
|
964
|
+
for (const msg of messages2) {
|
|
965
|
+
if (msg.sender) senders.add(msg.sender);
|
|
966
|
+
if (msg.channel) channelSet.add(msg.channel);
|
|
967
|
+
}
|
|
968
|
+
this.incrementActive(baseName, session, senders, channelSet);
|
|
969
|
+
const convIds = /* @__PURE__ */ new Set();
|
|
970
|
+
for (const msg of messages2) {
|
|
971
|
+
if (msg.payload.conversationId) convIds.add(msg.payload.conversationId);
|
|
972
|
+
}
|
|
973
|
+
for (const convId of convIds) {
|
|
974
|
+
await onDeliveredToMind(baseName, convId);
|
|
975
|
+
}
|
|
976
|
+
for (const ch of channelSet) {
|
|
977
|
+
linkInboundToActiveTurn(baseName, session, ch).catch(
|
|
978
|
+
(err) => dlog.warn(`failed to link mid-turn inbound for ${baseName}`, logger_default.errorData(err))
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
const typingMap = getTypingMap();
|
|
982
|
+
for (const ch of Object.keys(channels)) {
|
|
983
|
+
if (ch !== "unknown") typingMap.set(ch, baseName, { persistent: true });
|
|
984
|
+
}
|
|
985
|
+
const seenConvIds = /* @__PURE__ */ new Set();
|
|
986
|
+
for (const msg of messages2) {
|
|
987
|
+
if (msg.payload.conversationId && !seenConvIds.has(msg.payload.conversationId)) {
|
|
988
|
+
seenConvIds.add(msg.payload.conversationId);
|
|
989
|
+
typingMap.set(msg.payload.conversationId, baseName, { persistent: true });
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (seenConvIds.size > 0) {
|
|
993
|
+
publishTypingForChannels([...seenConvIds], typingMap);
|
|
994
|
+
}
|
|
995
|
+
const body = JSON.stringify({
|
|
996
|
+
session,
|
|
997
|
+
batch: { channels },
|
|
998
|
+
instructions: sessionConfig.instructions
|
|
999
|
+
});
|
|
1000
|
+
try {
|
|
1001
|
+
const ok = await this.postToMind(port, body);
|
|
1002
|
+
if (!ok) {
|
|
1003
|
+
this.decrementActive(baseName, session);
|
|
1004
|
+
publishTypingForChannels(typingMap.deleteSender(baseName), typingMap);
|
|
1005
|
+
await this.scheduleRetry(queueIds, { liveRejection: true });
|
|
1006
|
+
} else {
|
|
1007
|
+
await this.deleteQueueRows(queueIds);
|
|
1008
|
+
}
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
dlog.warn(`failed to deliver batch to ${mindName}`, logger_default.errorData(err));
|
|
1011
|
+
this.decrementActive(baseName, session);
|
|
1012
|
+
publishTypingForChannels(typingMap.deleteSender(baseName), typingMap);
|
|
1013
|
+
await this.scheduleRetry(queueIds, { liveRejection: false });
|
|
1014
|
+
} finally {
|
|
1015
|
+
for (const id of queueIds) this.inFlight.delete(id);
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
async enqueueBatch(mindName, session, payload, sessionConfig) {
|
|
1020
|
+
const delivery = sessionConfig.delivery;
|
|
1021
|
+
const queueId = await this.persistToQueue(mindName, session, payload);
|
|
1022
|
+
if (queueId != null) this.inFlight.add(queueId);
|
|
1023
|
+
const msg = {
|
|
1024
|
+
payload,
|
|
1025
|
+
channel: payload.channel,
|
|
1026
|
+
sender: payload.sender ?? null,
|
|
1027
|
+
createdAt: Date.now(),
|
|
1028
|
+
queueId
|
|
1029
|
+
};
|
|
1030
|
+
if (delivery.triggers?.length) {
|
|
1031
|
+
const text = extractTextContent(payload.content);
|
|
1032
|
+
const lower = text.toLowerCase();
|
|
1033
|
+
if (delivery.triggers.some((t) => lower.includes(t.toLowerCase()))) {
|
|
1034
|
+
await this.flushBatch(mindName, session, [msg]);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
this.addToBatchBuffer(mindName, session, sessionConfig, msg);
|
|
1039
|
+
}
|
|
1040
|
+
addToBatchBuffer(mindName, session, sessionConfig, msg) {
|
|
1041
|
+
const delivery = sessionConfig.delivery;
|
|
1042
|
+
const bufferKey = `${mindName}:${session}`;
|
|
1043
|
+
let buffer = this.batchBuffers.get(bufferKey);
|
|
1044
|
+
if (!buffer) {
|
|
1045
|
+
buffer = {
|
|
1046
|
+
messages: [],
|
|
1047
|
+
debounceTimer: null,
|
|
1048
|
+
maxWaitTimer: null,
|
|
1049
|
+
delivery
|
|
1050
|
+
};
|
|
1051
|
+
this.batchBuffers.set(bufferKey, buffer);
|
|
1052
|
+
}
|
|
1053
|
+
buffer.messages.push(msg);
|
|
1054
|
+
if (buffer.messages.length >= MAX_BATCH_SIZE) {
|
|
1055
|
+
this.flushBatch(mindName, session);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
this.scheduleBatchTimers(mindName, session, bufferKey);
|
|
1059
|
+
}
|
|
1060
|
+
scheduleBatchTimers(mindName, session, bufferKey) {
|
|
1061
|
+
const buffer = this.batchBuffers.get(bufferKey);
|
|
1062
|
+
if (!buffer) return;
|
|
1063
|
+
if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
|
|
1064
|
+
buffer.debounceTimer = setTimeout(() => {
|
|
1065
|
+
if (!this.isSessionBusy(mindName, session)) {
|
|
1066
|
+
this.flushBatch(mindName, session);
|
|
1067
|
+
}
|
|
1068
|
+
}, buffer.delivery.debounce * 1e3);
|
|
1069
|
+
buffer.debounceTimer.unref();
|
|
1070
|
+
if (!buffer.maxWaitTimer) {
|
|
1071
|
+
buffer.maxWaitTimer = setTimeout(() => {
|
|
1072
|
+
this.flushBatch(mindName, session);
|
|
1073
|
+
}, buffer.delivery.maxWait * 1e3);
|
|
1074
|
+
buffer.maxWaitTimer.unref();
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
async flushBatch(mindName, session, extra) {
|
|
1078
|
+
const bufferKey = `${mindName}:${session}`;
|
|
1079
|
+
const buffer = this.batchBuffers.get(bufferKey);
|
|
1080
|
+
const messages2 = [];
|
|
1081
|
+
if (buffer) {
|
|
1082
|
+
if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
|
|
1083
|
+
if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
|
|
1084
|
+
buffer.debounceTimer = null;
|
|
1085
|
+
buffer.maxWaitTimer = null;
|
|
1086
|
+
messages2.push(...buffer.messages.splice(0));
|
|
1087
|
+
this.batchBuffers.delete(bufferKey);
|
|
1088
|
+
}
|
|
1089
|
+
if (extra) messages2.push(...extra);
|
|
1090
|
+
if (messages2.length === 0) return;
|
|
1091
|
+
const baseName = await getBaseName(mindName);
|
|
1092
|
+
const config = getRoutingConfig(baseName);
|
|
1093
|
+
const sessionConfig = resolveDeliveryMode(config, session);
|
|
1094
|
+
dlog.info(`flushing batch for ${mindName}/${session}: ${messages2.length} messages`);
|
|
1095
|
+
this.deliverBatchToMind(mindName, session, messages2, sessionConfig).catch((err) => {
|
|
1096
|
+
dlog.warn(`failed to flush batch for ${mindName}/${session}`, logger_default.errorData(err));
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
async gateMessage(mindName, session, payload) {
|
|
1100
|
+
const baseName = await getBaseName(mindName);
|
|
1101
|
+
const declined = await this.isChannelDeclined(baseName, payload.channel);
|
|
1102
|
+
await this.persistToQueue(mindName, session, payload, declined ? "archived" : "gated");
|
|
1103
|
+
if (declined) return;
|
|
1104
|
+
try {
|
|
1105
|
+
const db = await getDb();
|
|
1106
|
+
const rows = await db.select({ count: sql`count(*)` }).from(deliveryQueue).where(
|
|
1107
|
+
and2(
|
|
1108
|
+
eq2(deliveryQueue.mind, baseName),
|
|
1109
|
+
eq2(deliveryQueue.channel, payload.channel),
|
|
1110
|
+
inArray(deliveryQueue.status, ["gated", "archived"])
|
|
1111
|
+
)
|
|
1112
|
+
);
|
|
1113
|
+
const count = rows[0]?.count ?? 0;
|
|
1114
|
+
if (count === 1 || count % GATED_NOTIFY_EVERY === 0) {
|
|
1115
|
+
await this.sendInviteNotification(mindName, payload, count);
|
|
1116
|
+
}
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
dlog.warn(`failed to check gated count for ${baseName}`, logger_default.errorData(err));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
async sendInviteNotification(mindName, payload, gatedCount = 1) {
|
|
1122
|
+
const text = extractTextContent(payload.content);
|
|
1123
|
+
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
|
1124
|
+
const channel = payload.channel ?? "unknown";
|
|
1125
|
+
const heldLine = gatedCount > 1 ? `${gatedCount} messages from this channel are being held, unrouted \u2014 you've not routed it yet.` : `Someone new is reaching out \u2014 you don't have a route for this channel yet.`;
|
|
1126
|
+
const detailLines = [
|
|
1127
|
+
payload.platform ? `Platform: ${payload.platform}` : null,
|
|
1128
|
+
payload.participantCount ? `Participants: ${payload.participantCount}` : null
|
|
1129
|
+
].filter((l) => l !== null);
|
|
1130
|
+
const details = detailLines.length > 0 ? `${detailLines.join("\n")}
|
|
1131
|
+
|
|
1132
|
+
` : "\n";
|
|
1133
|
+
const { getPrompt } = await import("./prompts-NBO3HRK3.js");
|
|
1134
|
+
const notification = await getPrompt("channel_invite", {
|
|
1135
|
+
channel,
|
|
1136
|
+
heldLine,
|
|
1137
|
+
sender: payload.sender ?? "unknown",
|
|
1138
|
+
details,
|
|
1139
|
+
preview,
|
|
1140
|
+
limit: String(GATED_RELEASE_LIMIT_PER_CHANNEL)
|
|
1141
|
+
});
|
|
1142
|
+
await this.notify(mindName, notification);
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Insert a delivery_queue row and return its id. The `mind` column is always keyed by
|
|
1146
|
+
* `baseName` so inserts under a variant name and the id-scoped cleanup use the same key
|
|
1147
|
+
* (fixes the variant mismatch where variant-keyed rows were never matched by the base
|
|
1148
|
+
* cleanup). `target_mind` records the original delivery target (`mindName`, which may be a
|
|
1149
|
+
* variant) so redrive resolves the port from it — a variant's stranded row is re-delivered
|
|
1150
|
+
* to the variant, not the parent.
|
|
1151
|
+
*/
|
|
1152
|
+
async persistToQueue(mindName, session, payload, status = "pending") {
|
|
1153
|
+
try {
|
|
1154
|
+
const baseName = await getBaseName(mindName);
|
|
1155
|
+
const db = await getDb();
|
|
1156
|
+
const result = await db.insert(deliveryQueue).values({
|
|
1157
|
+
mind: baseName,
|
|
1158
|
+
target_mind: mindName,
|
|
1159
|
+
thread: session,
|
|
1160
|
+
channel: payload.channel ?? null,
|
|
1161
|
+
sender: payload.sender ?? null,
|
|
1162
|
+
status,
|
|
1163
|
+
payload: JSON.stringify(payload)
|
|
1164
|
+
}).returning({ id: deliveryQueue.id });
|
|
1165
|
+
return result[0]?.id;
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
dlog.warn(
|
|
1168
|
+
`failed to persist to delivery queue for ${mindName}/${session}`,
|
|
1169
|
+
logger_default.errorData(err)
|
|
1170
|
+
);
|
|
1171
|
+
return void 0;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
async enrichWithProfiles(mindName, session, payload) {
|
|
1175
|
+
if (!payload.conversationId || !payload.channel) return payload;
|
|
1176
|
+
const mindSessions = this.sessionStates.get(mindName);
|
|
1177
|
+
const state2 = mindSessions?.get(session);
|
|
1178
|
+
if (!state2) return payload;
|
|
1179
|
+
const channelKey = payload.channel;
|
|
1180
|
+
if (state2.seenChannelProfiles.has(channelKey)) return payload;
|
|
1181
|
+
try {
|
|
1182
|
+
const participants = await getParticipants(payload.conversationId);
|
|
1183
|
+
const profiles = participants.map((p) => ({
|
|
1184
|
+
username: p.username,
|
|
1185
|
+
userType: p.userType,
|
|
1186
|
+
displayName: p.displayName,
|
|
1187
|
+
description: p.description
|
|
1188
|
+
}));
|
|
1189
|
+
const avatarBlocks = await this.loadAvatarBlocks(participants);
|
|
1190
|
+
state2.seenChannelProfiles.add(channelKey);
|
|
1191
|
+
const enriched = { ...payload, participantProfiles: profiles };
|
|
1192
|
+
if (avatarBlocks.length > 0) {
|
|
1193
|
+
const existing = Array.isArray(payload.content) ? payload.content : typeof payload.content === "string" ? [{ type: "text", text: payload.content }] : [];
|
|
1194
|
+
enriched.content = [...avatarBlocks, ...existing];
|
|
1195
|
+
}
|
|
1196
|
+
return enriched;
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
dlog.warn(`failed to fetch participant profiles for ${mindName}`, logger_default.errorData(err));
|
|
1199
|
+
return payload;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
async loadAvatarBlocks(participants) {
|
|
1203
|
+
const cacheKey = participants.map((p) => `${p.username}:${p.avatar ?? ""}`).sort().join(",");
|
|
1204
|
+
const cached = avatarBlocksCache.get(cacheKey);
|
|
1205
|
+
if (cached && cached.expiresAt > Date.now()) return cached.blocks;
|
|
1206
|
+
const blocks = [];
|
|
1207
|
+
for (const p of participants) {
|
|
1208
|
+
if (!p.avatar) continue;
|
|
1209
|
+
try {
|
|
1210
|
+
let filePath;
|
|
1211
|
+
if (p.userType === "mind") {
|
|
1212
|
+
const dir = mindDir(p.username);
|
|
1213
|
+
const config = readVoluteConfig(dir);
|
|
1214
|
+
if (!config?.profile?.avatar) continue;
|
|
1215
|
+
filePath = resolve(dir, "home", config.profile.avatar);
|
|
1216
|
+
const homeDir = resolve(dir, "home");
|
|
1217
|
+
if (!filePath.startsWith(`${homeDir}/`)) {
|
|
1218
|
+
dlog.warn(`avatar path for ${p.username} escapes home directory, skipping`);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
const realHome = await realpath(homeDir);
|
|
1223
|
+
const realAvatar = await realpath(filePath);
|
|
1224
|
+
if (!realAvatar.startsWith(`${realHome}/`)) {
|
|
1225
|
+
dlog.warn(
|
|
1226
|
+
`avatar symlink for ${p.username} resolves outside home directory, skipping`
|
|
1227
|
+
);
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
if (err.code === "ENOENT") continue;
|
|
1232
|
+
throw err;
|
|
1233
|
+
}
|
|
1234
|
+
} else {
|
|
1235
|
+
filePath = resolve(voluteHome(), "avatars", p.avatar);
|
|
1236
|
+
}
|
|
1237
|
+
const rendered = await renderAvatarBlock(filePath, p.username);
|
|
1238
|
+
if (rendered) blocks.push(...rendered);
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
const code = err.code;
|
|
1241
|
+
if (code !== "ENOENT") {
|
|
1242
|
+
dlog.warn(`failed to load avatar for ${p.username}`, logger_default.errorData(err));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
const now = Date.now();
|
|
1247
|
+
for (const [key, entry] of avatarBlocksCache) {
|
|
1248
|
+
if (entry.expiresAt <= now) avatarBlocksCache.delete(key);
|
|
1249
|
+
}
|
|
1250
|
+
avatarBlocksCache.set(cacheKey, { blocks, expiresAt: now + AVATAR_CACHE_TTL });
|
|
1251
|
+
return blocks;
|
|
1252
|
+
}
|
|
1253
|
+
incrementActive(mind, session, senders, channels) {
|
|
1254
|
+
let mindSessions = this.sessionStates.get(mind);
|
|
1255
|
+
if (!mindSessions) {
|
|
1256
|
+
mindSessions = /* @__PURE__ */ new Map();
|
|
1257
|
+
this.sessionStates.set(mind, mindSessions);
|
|
1258
|
+
}
|
|
1259
|
+
const state2 = mindSessions.get(session) ?? {
|
|
1260
|
+
activeCount: 0,
|
|
1261
|
+
lastDeliveredAt: 0,
|
|
1262
|
+
lastDeliverySenders: /* @__PURE__ */ new Set(),
|
|
1263
|
+
lastDeliveryChannels: /* @__PURE__ */ new Set(),
|
|
1264
|
+
seenChannelProfiles: /* @__PURE__ */ new Set()
|
|
1265
|
+
};
|
|
1266
|
+
state2.activeCount++;
|
|
1267
|
+
state2.lastDeliveredAt = Date.now();
|
|
1268
|
+
if (senders) state2.lastDeliverySenders = senders;
|
|
1269
|
+
if (channels) state2.lastDeliveryChannels = channels;
|
|
1270
|
+
mindSessions.set(session, state2);
|
|
1271
|
+
}
|
|
1272
|
+
decrementActive(mind, session) {
|
|
1273
|
+
const mindSessions = this.sessionStates.get(mind);
|
|
1274
|
+
if (!mindSessions) return;
|
|
1275
|
+
const state2 = mindSessions.get(session);
|
|
1276
|
+
if (!state2) return;
|
|
1277
|
+
state2.activeCount = Math.max(0, state2.activeCount - 1);
|
|
1278
|
+
if (state2.activeCount === 0) {
|
|
1279
|
+
const bufferKey = `${mind}:${session}`;
|
|
1280
|
+
const buffer = this.batchBuffers.get(bufferKey);
|
|
1281
|
+
if (buffer && buffer.messages.length > 0) {
|
|
1282
|
+
this.scheduleBatchTimers(mind, session, bufferKey);
|
|
1283
|
+
} else if (session.startsWith("new-")) {
|
|
1284
|
+
mindSessions.delete(session);
|
|
1285
|
+
if (mindSessions.size === 0) this.sessionStates.delete(mind);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
var instance2;
|
|
1291
|
+
function initDeliveryManager() {
|
|
1292
|
+
if (instance2) throw new Error("DeliveryManager already initialized");
|
|
1293
|
+
instance2 = new DeliveryManager();
|
|
1294
|
+
return instance2;
|
|
1295
|
+
}
|
|
1296
|
+
function getDeliveryManager() {
|
|
1297
|
+
if (!instance2) {
|
|
1298
|
+
throw new Error("DeliveryManager not initialized \u2014 call initDeliveryManager() first");
|
|
1299
|
+
}
|
|
1300
|
+
return instance2;
|
|
1301
|
+
}
|
|
1302
|
+
function tryGetDeliveryManager() {
|
|
1303
|
+
return instance2;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
export {
|
|
1307
|
+
getTypingMap,
|
|
1308
|
+
isConversationId,
|
|
1309
|
+
publishTypingForChannels,
|
|
1310
|
+
checkStaleSend,
|
|
1311
|
+
formatHoldNotice,
|
|
1312
|
+
MAX_DELIVERY_ATTEMPTS,
|
|
1313
|
+
DeliveryManager,
|
|
1314
|
+
initDeliveryManager,
|
|
1315
|
+
getDeliveryManager,
|
|
1316
|
+
tryGetDeliveryManager
|
|
1317
|
+
};
|