@swarmclawai/swarmclaw 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +81 -22
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +36 -7
  5. package/src/app/api/agents/route.ts +12 -1
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  9. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  10. package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
  11. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  12. package/src/app/api/chats/[id]/route.ts +18 -0
  13. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  14. package/src/app/api/chats/route.ts +16 -0
  15. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  16. package/src/app/api/connectors/doctor/route.ts +13 -0
  17. package/src/app/api/files/open/route.ts +16 -14
  18. package/src/app/api/memory/maintenance/route.ts +11 -1
  19. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  20. package/src/app/api/openclaw/skills/route.ts +11 -3
  21. package/src/app/api/plugins/dependencies/route.ts +24 -0
  22. package/src/app/api/plugins/install/route.ts +15 -92
  23. package/src/app/api/plugins/route.ts +3 -26
  24. package/src/app/api/plugins/settings/route.ts +17 -12
  25. package/src/app/api/plugins/ui/route.ts +1 -0
  26. package/src/app/api/settings/route.ts +49 -7
  27. package/src/app/api/tasks/[id]/route.ts +15 -6
  28. package/src/app/api/tasks/bulk/route.ts +2 -2
  29. package/src/app/api/tasks/route.ts +9 -4
  30. package/src/app/api/webhooks/[id]/route.ts +8 -1
  31. package/src/app/page.tsx +9 -2
  32. package/src/cli/index.js +4 -0
  33. package/src/cli/index.ts +3 -10
  34. package/src/components/agents/agent-card.tsx +15 -12
  35. package/src/components/agents/agent-chat-list.tsx +101 -1
  36. package/src/components/agents/agent-list.tsx +46 -9
  37. package/src/components/agents/agent-sheet.tsx +207 -16
  38. package/src/components/agents/inspector-panel.tsx +108 -48
  39. package/src/components/auth/access-key-gate.tsx +36 -97
  40. package/src/components/chat/chat-area.tsx +29 -13
  41. package/src/components/chat/chat-card.tsx +4 -20
  42. package/src/components/chat/chat-header.tsx +255 -353
  43. package/src/components/chat/chat-list.tsx +7 -9
  44. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  45. package/src/components/chat/message-list.tsx +3 -1
  46. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  47. package/src/components/connectors/connector-list.tsx +265 -127
  48. package/src/components/connectors/connector-sheet.tsx +217 -0
  49. package/src/components/home/home-view.tsx +128 -4
  50. package/src/components/layout/app-layout.tsx +383 -194
  51. package/src/components/layout/mobile-header.tsx +26 -8
  52. package/src/components/plugins/plugin-list.tsx +15 -3
  53. package/src/components/plugins/plugin-sheet.tsx +118 -9
  54. package/src/components/projects/project-detail.tsx +183 -0
  55. package/src/components/shared/agent-picker-list.tsx +2 -2
  56. package/src/components/shared/command-palette.tsx +111 -24
  57. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  58. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  59. package/src/components/shared/settings/section-heartbeat.tsx +77 -0
  60. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  61. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  62. package/src/components/shared/settings/section-secrets.tsx +6 -6
  63. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  64. package/src/components/shared/settings/section-voice.tsx +5 -1
  65. package/src/components/shared/settings/section-web-search.tsx +10 -2
  66. package/src/components/shared/settings/settings-page.tsx +245 -46
  67. package/src/components/tasks/approvals-panel.tsx +205 -18
  68. package/src/components/tasks/task-board.tsx +242 -46
  69. package/src/components/usage/metrics-dashboard.tsx +74 -1
  70. package/src/components/wallets/wallet-panel.tsx +17 -5
  71. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  72. package/src/lib/auth.ts +17 -0
  73. package/src/lib/chat-streaming-state.test.ts +108 -0
  74. package/src/lib/chat-streaming-state.ts +108 -0
  75. package/src/lib/openclaw-agent-id.test.ts +14 -0
  76. package/src/lib/openclaw-agent-id.ts +31 -0
  77. package/src/lib/server/agent-assignment.test.ts +112 -0
  78. package/src/lib/server/agent-assignment.ts +169 -0
  79. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  80. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  81. package/src/lib/server/approvals.ts +483 -75
  82. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  83. package/src/lib/server/browser-state.test.ts +118 -0
  84. package/src/lib/server/browser-state.ts +123 -0
  85. package/src/lib/server/build-llm.test.ts +36 -0
  86. package/src/lib/server/build-llm.ts +11 -4
  87. package/src/lib/server/builtin-plugins.ts +34 -0
  88. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  89. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  90. package/src/lib/server/chat-execution.ts +250 -61
  91. package/src/lib/server/chatroom-health.test.ts +26 -0
  92. package/src/lib/server/chatroom-health.ts +2 -3
  93. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  94. package/src/lib/server/chatroom-helpers.ts +45 -5
  95. package/src/lib/server/connectors/discord.ts +175 -11
  96. package/src/lib/server/connectors/doctor.test.ts +80 -0
  97. package/src/lib/server/connectors/doctor.ts +116 -0
  98. package/src/lib/server/connectors/manager.ts +946 -110
  99. package/src/lib/server/connectors/policy.test.ts +222 -0
  100. package/src/lib/server/connectors/policy.ts +452 -0
  101. package/src/lib/server/connectors/slack.ts +188 -9
  102. package/src/lib/server/connectors/telegram.ts +65 -15
  103. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  104. package/src/lib/server/connectors/thread-context.ts +72 -0
  105. package/src/lib/server/connectors/types.ts +41 -11
  106. package/src/lib/server/daemon-state.ts +59 -1
  107. package/src/lib/server/data-dir.ts +13 -0
  108. package/src/lib/server/delegation-jobs.test.ts +140 -0
  109. package/src/lib/server/delegation-jobs.ts +248 -0
  110. package/src/lib/server/document-utils.test.ts +47 -0
  111. package/src/lib/server/document-utils.ts +397 -0
  112. package/src/lib/server/heartbeat-service.ts +13 -39
  113. package/src/lib/server/heartbeat-source.test.ts +22 -0
  114. package/src/lib/server/heartbeat-source.ts +7 -0
  115. package/src/lib/server/identity-continuity.test.ts +77 -0
  116. package/src/lib/server/identity-continuity.ts +127 -0
  117. package/src/lib/server/mailbox-utils.ts +347 -0
  118. package/src/lib/server/main-agent-loop.ts +27 -967
  119. package/src/lib/server/memory-db.ts +4 -6
  120. package/src/lib/server/memory-tiers.ts +40 -0
  121. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  122. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  123. package/src/lib/server/openclaw-exec-config.ts +5 -6
  124. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  125. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  126. package/src/lib/server/openclaw-sync.ts +3 -2
  127. package/src/lib/server/orchestrator-lg.ts +17 -6
  128. package/src/lib/server/orchestrator.ts +2 -2
  129. package/src/lib/server/playwright-proxy.mjs +27 -3
  130. package/src/lib/server/plugins.test.ts +207 -0
  131. package/src/lib/server/plugins.ts +822 -69
  132. package/src/lib/server/provider-health.ts +33 -3
  133. package/src/lib/server/queue.ts +3 -20
  134. package/src/lib/server/scheduler.ts +2 -0
  135. package/src/lib/server/session-archive-memory.test.ts +85 -0
  136. package/src/lib/server/session-archive-memory.ts +230 -0
  137. package/src/lib/server/session-mailbox.ts +8 -18
  138. package/src/lib/server/session-reset-policy.test.ts +99 -0
  139. package/src/lib/server/session-reset-policy.ts +311 -0
  140. package/src/lib/server/session-run-manager.ts +33 -80
  141. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  142. package/src/lib/server/session-tools/calendar.ts +2 -12
  143. package/src/lib/server/session-tools/connector.ts +109 -8
  144. package/src/lib/server/session-tools/context.ts +14 -2
  145. package/src/lib/server/session-tools/crawl.ts +447 -0
  146. package/src/lib/server/session-tools/crud.ts +70 -32
  147. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  148. package/src/lib/server/session-tools/delegate.ts +406 -20
  149. package/src/lib/server/session-tools/discovery.ts +22 -4
  150. package/src/lib/server/session-tools/document.ts +283 -0
  151. package/src/lib/server/session-tools/email.ts +1 -3
  152. package/src/lib/server/session-tools/extract.ts +137 -0
  153. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  154. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  155. package/src/lib/server/session-tools/file.ts +237 -24
  156. package/src/lib/server/session-tools/human-loop.ts +227 -0
  157. package/src/lib/server/session-tools/image-gen.ts +1 -3
  158. package/src/lib/server/session-tools/index.ts +56 -1
  159. package/src/lib/server/session-tools/mailbox.ts +276 -0
  160. package/src/lib/server/session-tools/memory.ts +35 -3
  161. package/src/lib/server/session-tools/monitor.ts +150 -7
  162. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  163. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  164. package/src/lib/server/session-tools/platform.ts +142 -4
  165. package/src/lib/server/session-tools/plugin-creator.ts +86 -23
  166. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  167. package/src/lib/server/session-tools/replicate.ts +1 -3
  168. package/src/lib/server/session-tools/schedule.ts +20 -10
  169. package/src/lib/server/session-tools/session-info.ts +36 -3
  170. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  171. package/src/lib/server/session-tools/subagent.ts +193 -27
  172. package/src/lib/server/session-tools/table.ts +587 -0
  173. package/src/lib/server/session-tools/wallet.ts +13 -10
  174. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  175. package/src/lib/server/session-tools/web.ts +896 -100
  176. package/src/lib/server/storage.ts +226 -7
  177. package/src/lib/server/stream-agent-chat.ts +46 -21
  178. package/src/lib/server/structured-extract.test.ts +72 -0
  179. package/src/lib/server/structured-extract.ts +373 -0
  180. package/src/lib/server/task-mention.test.ts +16 -2
  181. package/src/lib/server/task-mention.ts +61 -10
  182. package/src/lib/server/tool-aliases.ts +44 -7
  183. package/src/lib/server/tool-capability-policy.ts +6 -0
  184. package/src/lib/server/tool-retry.ts +2 -0
  185. package/src/lib/server/watch-jobs.test.ts +173 -0
  186. package/src/lib/server/watch-jobs.ts +532 -0
  187. package/src/lib/server/ws-hub.ts +5 -3
  188. package/src/lib/validation/schemas.test.ts +26 -0
  189. package/src/lib/validation/schemas.ts +7 -0
  190. package/src/lib/ws-client.ts +14 -12
  191. package/src/proxy.ts +5 -5
  192. package/src/stores/use-app-store.ts +0 -6
  193. package/src/stores/use-chat-store.ts +31 -2
  194. package/src/types/index.ts +287 -44
  195. package/src/components/chat/new-chat-sheet.tsx +0 -253
  196. package/src/lib/server/main-session.ts +0 -17
  197. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -0,0 +1,127 @@
1
+ import type { Agent, IdentityContinuityState, Session } from '@/types'
2
+
3
+ function normalizeText(value: unknown, maxChars: number): string | null {
4
+ if (typeof value !== 'string') return null
5
+ const normalized = value.replace(/\s+/g, ' ').trim()
6
+ return normalized ? normalized.slice(0, maxChars) : null
7
+ }
8
+
9
+ function normalizeList(value: unknown, maxItems: number, maxChars: number): string[] {
10
+ if (!Array.isArray(value)) return []
11
+ const seen = new Set<string>()
12
+ const out: string[] = []
13
+ for (const raw of value) {
14
+ const normalized = normalizeText(raw, maxChars)
15
+ if (!normalized) continue
16
+ const key = normalized.toLowerCase()
17
+ if (seen.has(key)) continue
18
+ seen.add(key)
19
+ out.push(normalized)
20
+ if (out.length >= maxItems) break
21
+ }
22
+ return out
23
+ }
24
+
25
+ export function normalizeIdentityContinuityState(raw: unknown): IdentityContinuityState | null {
26
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
27
+ const record = raw as Record<string, unknown>
28
+ const state: IdentityContinuityState = {
29
+ selfSummary: normalizeText(record.selfSummary, 320),
30
+ relationshipSummary: normalizeText(record.relationshipSummary, 320),
31
+ personaLabel: normalizeText(record.personaLabel, 120),
32
+ toneStyle: normalizeText(record.toneStyle, 120),
33
+ boundaries: normalizeList(record.boundaries, 6, 180),
34
+ continuityNotes: normalizeList(record.continuityNotes, 8, 220),
35
+ updatedAt: typeof record.updatedAt === 'number' && Number.isFinite(record.updatedAt)
36
+ ? Math.trunc(record.updatedAt)
37
+ : null,
38
+ }
39
+ return state
40
+ }
41
+
42
+ function fallbackSelfSummary(agent?: Partial<Agent> | null): string | null {
43
+ const description = normalizeText(agent?.description, 220)
44
+ if (description) return `${agent?.name || 'Agent'}: ${description}`
45
+ const soul = normalizeText(agent?.soul, 220)
46
+ if (soul) return `${agent?.name || 'Agent'}: ${soul}`
47
+ const name = normalizeText(agent?.name, 80)
48
+ return name ? `${name}: persistent companion agent` : null
49
+ }
50
+
51
+ function fallbackPersonaLabel(session?: Partial<Session> | null, agent?: Partial<Agent> | null): string | null {
52
+ const threadPersona = normalizeText(session?.connectorContext?.threadPersonaLabel, 120)
53
+ if (threadPersona) return threadPersona
54
+ const threadTitle = normalizeText(session?.connectorContext?.threadTitle, 120)
55
+ if (threadTitle) return threadTitle
56
+ const threadId = normalizeText(session?.connectorContext?.threadId, 80)
57
+ if (threadId) return `${agent?.name || 'Agent'} thread ${threadId}`
58
+ const sessionName = normalizeText(session?.name, 120)
59
+ if (sessionName && !/^new chat$/i.test(sessionName)) return sessionName
60
+ return null
61
+ }
62
+
63
+ function fallbackRelationshipSummary(session?: Partial<Session> | null): string | null {
64
+ const sender = normalizeText(session?.connectorContext?.senderName, 80)
65
+ if (sender) return `Ongoing conversation with ${sender}.`
66
+ const user = normalizeText(session?.user, 80)
67
+ if (user && user !== 'user') return `Ongoing conversation with ${user}.`
68
+ return 'Ongoing conversation with the user.'
69
+ }
70
+
71
+ export function buildIdentityContinuityContext(
72
+ session?: Partial<Session> | null,
73
+ agent?: Partial<Agent> | null,
74
+ ): string {
75
+ const agentState = normalizeIdentityContinuityState(agent?.identityState)
76
+ const sessionState = normalizeIdentityContinuityState(session?.identityState)
77
+ const selfSummary = sessionState?.selfSummary || agentState?.selfSummary || fallbackSelfSummary(agent)
78
+ const relationshipSummary = sessionState?.relationshipSummary || agentState?.relationshipSummary || fallbackRelationshipSummary(session)
79
+ const personaLabel = sessionState?.personaLabel || fallbackPersonaLabel(session, agent)
80
+ const toneStyle = sessionState?.toneStyle || normalizeText(session?.conversationTone, 80) || agentState?.toneStyle
81
+ const boundaries = sessionState?.boundaries?.length
82
+ ? sessionState.boundaries
83
+ : agentState?.boundaries?.length
84
+ ? agentState.boundaries
85
+ : []
86
+ const continuityNotes = [
87
+ ...(agentState?.continuityNotes || []),
88
+ ...(sessionState?.continuityNotes || []),
89
+ ].slice(-6)
90
+
91
+ const lines: string[] = []
92
+ if (selfSummary) lines.push(`Core self: ${selfSummary}`)
93
+ if (personaLabel) lines.push(`Current persona: ${personaLabel}`)
94
+ if (relationshipSummary) lines.push(`Relationship context: ${relationshipSummary}`)
95
+ if (toneStyle) lines.push(`Observed tone: ${toneStyle}`)
96
+ if (boundaries.length) lines.push(`Boundaries: ${boundaries.join(' | ')}`)
97
+ if (continuityNotes.length) lines.push(`Continuity notes: ${continuityNotes.join(' | ')}`)
98
+ if (!lines.length) return ''
99
+ return `## Identity Continuity\n${lines.join('\n')}`
100
+ }
101
+
102
+ export function refreshSessionIdentityState(
103
+ session: Session,
104
+ agent?: Partial<Agent> | null,
105
+ now = Date.now(),
106
+ ): IdentityContinuityState {
107
+ const existing = normalizeIdentityContinuityState(session.identityState) || {}
108
+ const agentState = normalizeIdentityContinuityState(agent?.identityState) || {}
109
+ const boundaries = existing.boundaries?.length ? existing.boundaries : (agentState.boundaries || [])
110
+ const continuityNotes = [
111
+ ...(agentState.continuityNotes || []),
112
+ ...(existing.continuityNotes || []),
113
+ ].slice(-8)
114
+
115
+ const next: IdentityContinuityState = {
116
+ selfSummary: existing.selfSummary || agentState.selfSummary || fallbackSelfSummary(agent),
117
+ relationshipSummary: existing.relationshipSummary || agentState.relationshipSummary || fallbackRelationshipSummary(session),
118
+ personaLabel: existing.personaLabel || fallbackPersonaLabel(session, agent),
119
+ toneStyle: normalizeText(session.conversationTone, 80) || existing.toneStyle || agentState.toneStyle,
120
+ boundaries,
121
+ continuityNotes,
122
+ updatedAt: now,
123
+ }
124
+
125
+ session.identityState = next
126
+ return next
127
+ }
@@ -0,0 +1,347 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { ImapFlow } from 'imapflow'
4
+ import { createTransport } from 'nodemailer'
5
+ import { simpleParser } from 'mailparser'
6
+ import { UPLOAD_DIR, loadConnectors } from './storage'
7
+ import { getPluginManager } from './plugins'
8
+
9
+ export interface MailboxConfig {
10
+ imapHost: string
11
+ imapPort: number
12
+ smtpHost: string
13
+ smtpPort: number
14
+ user: string
15
+ password: string
16
+ smtpUsername: string
17
+ smtpPassword: string
18
+ folder: string
19
+ subjectPrefix?: string
20
+ fromAddress: string
21
+ fromName: string
22
+ }
23
+
24
+ export interface MailboxAttachment {
25
+ id: string
26
+ filename: string
27
+ contentType: string | null
28
+ sizeBytes: number
29
+ }
30
+
31
+ export interface MailboxMessage {
32
+ id: string
33
+ uid: number
34
+ messageId: string | null
35
+ subject: string
36
+ from: string
37
+ fromName: string
38
+ date: string | null
39
+ snippet: string
40
+ text: string
41
+ html: string | null
42
+ threadKey: string
43
+ references: string[]
44
+ hasAttachments: boolean
45
+ attachments: MailboxAttachment[]
46
+ flags: string[]
47
+ }
48
+
49
+ function pickString(...values: unknown[]): string {
50
+ for (const value of values) {
51
+ if (typeof value !== 'string') continue
52
+ const trimmed = value.trim()
53
+ if (trimmed) return trimmed
54
+ }
55
+ return ''
56
+ }
57
+
58
+ function pickNumber(fallback: number, ...values: unknown[]): number {
59
+ for (const value of values) {
60
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN
61
+ if (Number.isFinite(parsed) && parsed > 0) return Math.trunc(parsed)
62
+ }
63
+ return fallback
64
+ }
65
+
66
+ function normalizeThreadKey(subject: string, references: string[]): string {
67
+ if (references.length > 0) return references[references.length - 1]
68
+ return subject.replace(/^re:\s*/i, '').trim().toLowerCase()
69
+ }
70
+
71
+ function sanitizeAttachmentName(value: string | undefined, fallback: string): string {
72
+ const cleaned = String(value || '').replace(/[^a-zA-Z0-9._-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '')
73
+ return cleaned || fallback
74
+ }
75
+
76
+ export function getMailboxConfig(): MailboxConfig {
77
+ const pluginManager = getPluginManager()
78
+ const mailboxSettings = pluginManager.getPluginSettings('mailbox') as Record<string, unknown>
79
+ const emailSettings = pluginManager.getPluginSettings('email') as Record<string, unknown>
80
+ const connectors = loadConnectors()
81
+ const emailConnector = Object.values(connectors)
82
+ .find((entry) => entry && typeof entry === 'object' && String((entry as Record<string, unknown>).platform || '').toLowerCase() === 'email') as Record<string, unknown> | undefined
83
+ const connectorConfig = emailConnector && typeof emailConnector.config === 'object' && emailConnector.config
84
+ ? emailConnector.config as Record<string, unknown>
85
+ : {}
86
+
87
+ const user = pickString(mailboxSettings.user, connectorConfig.user)
88
+ const password = pickString(mailboxSettings.password, connectorConfig.password)
89
+
90
+ return {
91
+ imapHost: pickString(mailboxSettings.imapHost, connectorConfig.imapHost),
92
+ imapPort: pickNumber(993, mailboxSettings.imapPort, connectorConfig.imapPort),
93
+ smtpHost: pickString(mailboxSettings.smtpHost, emailSettings.host, connectorConfig.smtpHost),
94
+ smtpPort: pickNumber(587, mailboxSettings.smtpPort, emailSettings.port, connectorConfig.smtpPort),
95
+ user,
96
+ password,
97
+ smtpUsername: pickString(mailboxSettings.smtpUsername, emailSettings.username, connectorConfig.user, user),
98
+ smtpPassword: pickString(mailboxSettings.smtpPassword, emailSettings.password, connectorConfig.password, password),
99
+ folder: pickString(mailboxSettings.folder, connectorConfig.folder, 'INBOX') || 'INBOX',
100
+ subjectPrefix: pickString(mailboxSettings.subjectPrefix, connectorConfig.subjectPrefix) || undefined,
101
+ fromAddress: pickString(mailboxSettings.fromAddress, emailSettings.fromAddress, connectorConfig.user, user),
102
+ fromName: pickString(mailboxSettings.fromName, emailSettings.fromName, 'SwarmClaw Agent'),
103
+ }
104
+ }
105
+
106
+ function ensureMailboxConfigured(config: MailboxConfig): void {
107
+ if (!config.imapHost || !config.user || !config.password) {
108
+ throw new Error('Mailbox plugin requires IMAP host, user, and password.')
109
+ }
110
+ }
111
+
112
+ async function withImapClient<T>(config: MailboxConfig, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
113
+ ensureMailboxConfigured(config)
114
+ const client = new ImapFlow({
115
+ host: config.imapHost,
116
+ port: config.imapPort,
117
+ secure: config.imapPort === 993,
118
+ auth: {
119
+ user: config.user,
120
+ pass: config.password,
121
+ },
122
+ logger: false,
123
+ })
124
+ await client.connect()
125
+ try {
126
+ return await fn(client)
127
+ } finally {
128
+ try { await client.logout() } catch { /* ignore */ }
129
+ }
130
+ }
131
+
132
+ function messageMatchesFilters(message: MailboxMessage, filters: {
133
+ query?: string
134
+ from?: string
135
+ subjectContains?: string
136
+ bodyContains?: string
137
+ unreadOnly?: boolean
138
+ hasAttachments?: boolean
139
+ uidGreaterThan?: number
140
+ }) {
141
+ if (typeof filters.uidGreaterThan === 'number' && message.uid <= filters.uidGreaterThan) return false
142
+ if (filters.unreadOnly === true && message.flags.includes('\\Seen')) return false
143
+ if (filters.hasAttachments === true && !message.hasAttachments) return false
144
+ const from = filters.from?.trim().toLowerCase()
145
+ if (from && !message.from.toLowerCase().includes(from) && !message.fromName.toLowerCase().includes(from)) return false
146
+ const subjectContains = filters.subjectContains?.trim().toLowerCase()
147
+ if (subjectContains && !message.subject.toLowerCase().includes(subjectContains)) return false
148
+ const bodyContains = filters.bodyContains?.trim().toLowerCase()
149
+ if (bodyContains && !message.text.toLowerCase().includes(bodyContains)) return false
150
+ const query = filters.query?.trim().toLowerCase()
151
+ if (query) {
152
+ const hay = `${message.subject}\n${message.from}\n${message.fromName}\n${message.text}`.toLowerCase()
153
+ if (!hay.includes(query)) return false
154
+ }
155
+ return true
156
+ }
157
+
158
+ function toMailboxMessage(raw: {
159
+ uid: number
160
+ envelope?: {
161
+ from?: Array<{ name?: string; address?: string }>
162
+ subject?: string
163
+ messageId?: string
164
+ date?: Date
165
+ inReplyTo?: string
166
+ references?: string[]
167
+ }
168
+ flags?: Set<string>
169
+ source?: Buffer
170
+ }, parsed: Awaited<ReturnType<typeof simpleParser>>): MailboxMessage {
171
+ const fromAddress = raw.envelope?.from?.[0]?.address || parsed.from?.value?.[0]?.address || 'unknown'
172
+ const fromName = raw.envelope?.from?.[0]?.name || parsed.from?.value?.[0]?.name || fromAddress
173
+ const references = [
174
+ ...(Array.isArray(raw.envelope?.references) ? raw.envelope?.references : []),
175
+ ...(parsed.references ? (Array.isArray(parsed.references) ? parsed.references : [parsed.references]) : []),
176
+ ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
177
+
178
+ return {
179
+ id: String(raw.uid),
180
+ uid: raw.uid,
181
+ messageId: raw.envelope?.messageId || parsed.messageId || null,
182
+ subject: raw.envelope?.subject || parsed.subject || '(no subject)',
183
+ from: fromAddress,
184
+ fromName,
185
+ date: raw.envelope?.date ? raw.envelope.date.toISOString() : (parsed.date ? parsed.date.toISOString() : null),
186
+ snippet: (parsed.text || parsed.html || '').replace(/\s+/g, ' ').trim().slice(0, 240),
187
+ text: (parsed.text || '').trim(),
188
+ html: typeof parsed.html === 'string' ? parsed.html : null,
189
+ threadKey: normalizeThreadKey(raw.envelope?.subject || parsed.subject || '', references),
190
+ references,
191
+ hasAttachments: parsed.attachments.length > 0,
192
+ attachments: parsed.attachments.map((attachment, index) => ({
193
+ id: `${raw.uid}:${index}`,
194
+ filename: sanitizeAttachmentName(attachment.filename || undefined, `attachment-${index + 1}`),
195
+ contentType: attachment.contentType || null,
196
+ sizeBytes: attachment.size || 0,
197
+ })),
198
+ flags: Array.from(raw.flags || new Set<string>()),
199
+ }
200
+ }
201
+
202
+ export async function getMailboxHighwaterUid(config = getMailboxConfig(), folder?: string): Promise<number> {
203
+ return withImapClient(config, async (client) => {
204
+ const targetFolder = folder || config.folder || 'INBOX'
205
+ const lock = await client.getMailboxLock(targetFolder)
206
+ try {
207
+ const status = await client.status(targetFolder, { uidNext: true })
208
+ return typeof status.uidNext === 'number' ? Math.max(0, status.uidNext - 1) : 0
209
+ } finally {
210
+ lock.release()
211
+ }
212
+ })
213
+ }
214
+
215
+ export async function fetchMailboxMessages(filters?: {
216
+ folder?: string
217
+ query?: string
218
+ from?: string
219
+ subjectContains?: string
220
+ bodyContains?: string
221
+ unreadOnly?: boolean
222
+ hasAttachments?: boolean
223
+ uidGreaterThan?: number
224
+ limit?: number
225
+ }): Promise<MailboxMessage[]> {
226
+ const config = getMailboxConfig()
227
+ return withImapClient(config, async (client) => {
228
+ const folder = filters?.folder || config.folder || 'INBOX'
229
+ const limit = Math.max(1, Math.min(filters?.limit || 20, 100))
230
+ const lock = await client.getMailboxLock(folder)
231
+ try {
232
+ const status = await client.status(folder, { uidNext: true })
233
+ const endUid = typeof status.uidNext === 'number' ? Math.max(0, status.uidNext - 1) : 0
234
+ if (endUid <= 0) return []
235
+ const startUid = Math.max(1, endUid - Math.max(limit * 4, 60) + 1)
236
+ const messages: MailboxMessage[] = []
237
+ for await (const raw of client.fetch(`${startUid}:${endUid}`, {
238
+ uid: true,
239
+ envelope: true,
240
+ flags: true,
241
+ source: true,
242
+ }, { uid: true })) {
243
+ if (!raw.source) continue
244
+ const parsed = await simpleParser(raw.source)
245
+ const message = toMailboxMessage(raw, parsed)
246
+ if (!messageMatchesFilters(message, filters || {})) continue
247
+ if (config.subjectPrefix && !message.subject.startsWith(config.subjectPrefix)) continue
248
+ messages.push(message)
249
+ }
250
+ return messages.sort((a, b) => b.uid - a.uid).slice(0, limit)
251
+ } finally {
252
+ lock.release()
253
+ }
254
+ })
255
+ }
256
+
257
+ export async function fetchMailboxMessageByUid(uid: number, folder?: string): Promise<MailboxMessage | null> {
258
+ const messages = await fetchMailboxMessages({ folder, uidGreaterThan: uid - 1, limit: 100 })
259
+ return messages.find((message) => message.uid === uid) || null
260
+ }
261
+
262
+ export async function downloadMailboxAttachment(params: {
263
+ uid: number
264
+ attachmentId?: string
265
+ attachmentName?: string
266
+ folder?: string
267
+ saveTo?: string
268
+ cwd?: string
269
+ }): Promise<{ filePath: string; fileName: string; url: string | null }> {
270
+ const config = getMailboxConfig()
271
+ return withImapClient(config, async (client) => {
272
+ const folder = params.folder || config.folder || 'INBOX'
273
+ const lock = await client.getMailboxLock(folder)
274
+ try {
275
+ for await (const raw of client.fetch(String(params.uid), { uid: true, source: true }, { uid: true })) {
276
+ if (!raw.source) continue
277
+ const parsed = await simpleParser(raw.source)
278
+ const selected = parsed.attachments.find((attachment, index) => {
279
+ const generatedId = `${params.uid}:${index}`
280
+ if (params.attachmentId && generatedId === params.attachmentId) return true
281
+ if (params.attachmentName && attachment.filename === params.attachmentName) return true
282
+ return !params.attachmentId && !params.attachmentName && index === 0
283
+ })
284
+ if (!selected) throw new Error('Attachment not found.')
285
+
286
+ const fileName = sanitizeAttachmentName(selected.filename || undefined, `attachment-${params.uid}`)
287
+ const targetPath = params.saveTo
288
+ ? path.resolve(params.cwd || process.cwd(), params.saveTo)
289
+ : path.join(UPLOAD_DIR, `${Date.now()}-${fileName}`)
290
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true })
291
+ fs.writeFileSync(targetPath, selected.content)
292
+
293
+ const publicPath = targetPath.startsWith(UPLOAD_DIR)
294
+ ? targetPath
295
+ : path.join(UPLOAD_DIR, `${Date.now()}-${path.basename(targetPath)}`)
296
+ if (publicPath !== targetPath) fs.copyFileSync(targetPath, publicPath)
297
+ return {
298
+ filePath: targetPath,
299
+ fileName,
300
+ url: `/api/uploads/${path.basename(publicPath)}`,
301
+ }
302
+ }
303
+ throw new Error(`Mailbox message not found: ${params.uid}`)
304
+ } finally {
305
+ lock.release()
306
+ }
307
+ })
308
+ }
309
+
310
+ export async function replyMailboxMessage(params: {
311
+ uid: number
312
+ text: string
313
+ html?: string
314
+ subject?: string
315
+ folder?: string
316
+ }): Promise<{ to: string; subject: string }> {
317
+ const config = getMailboxConfig()
318
+ if (!config.smtpHost || !config.fromAddress) {
319
+ throw new Error('Mailbox reply requires SMTP host and fromAddress configuration.')
320
+ }
321
+
322
+ const message = await fetchMailboxMessageByUid(params.uid, params.folder)
323
+ if (!message) throw new Error(`Mailbox message not found: ${params.uid}`)
324
+
325
+ const transport = createTransport({
326
+ host: config.smtpHost,
327
+ port: config.smtpPort,
328
+ secure: config.smtpPort === 465,
329
+ auth: {
330
+ user: config.smtpUsername || config.user,
331
+ pass: config.smtpPassword || config.password,
332
+ },
333
+ })
334
+
335
+ const subject = params.subject?.trim() || `Re: ${message.subject.replace(/^Re:\s*/i, '')}`
336
+ await transport.sendMail({
337
+ from: config.fromName ? `"${config.fromName}" <${config.fromAddress}>` : config.fromAddress,
338
+ to: message.from,
339
+ subject,
340
+ text: params.text,
341
+ html: params.html,
342
+ inReplyTo: message.messageId || undefined,
343
+ references: message.messageId || undefined,
344
+ })
345
+
346
+ return { to: message.from, subject }
347
+ }