@swarmclawai/swarmclaw 0.7.2 → 0.7.4
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 +116 -50
- package/bin/package-manager.js +157 -0
- package/bin/package-manager.test.js +90 -0
- package/bin/server-cmd.js +38 -7
- package/bin/swarmclaw.js +54 -4
- package/bin/update-cmd.js +48 -10
- package/bin/update-cmd.test.js +55 -0
- package/package.json +8 -3
- package/scripts/postinstall.mjs +26 -0
- package/src/app/api/agents/[id]/route.ts +43 -0
- package/src/app/api/agents/[id]/thread/route.ts +39 -8
- package/src/app/api/agents/route.ts +35 -2
- package/src/app/api/auth/route.ts +77 -8
- package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
- package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/route.ts +6 -0
- package/src/app/api/chats/[id]/browser/route.ts +5 -1
- package/src/app/api/chats/[id]/chat/route.ts +7 -3
- package/src/app/api/chats/[id]/messages/route.ts +19 -13
- package/src/app/api/chats/[id]/route.ts +30 -0
- package/src/app/api/chats/[id]/stop/route.ts +6 -1
- package/src/app/api/chats/heartbeat/route.ts +2 -1
- package/src/app/api/chats/route.ts +23 -1
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
- package/src/app/api/connectors/doctor/route.ts +13 -0
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
- package/src/app/api/external-agents/[id]/route.ts +31 -0
- package/src/app/api/external-agents/register/route.ts +3 -0
- package/src/app/api/external-agents/route.ts +66 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/gateways/[id]/health/route.ts +28 -0
- package/src/app/api/gateways/[id]/route.ts +79 -0
- package/src/app/api/gateways/route.ts +57 -0
- package/src/app/api/memory/maintenance/route.ts +11 -1
- package/src/app/api/openclaw/agent-files/route.ts +27 -4
- package/src/app/api/openclaw/gateway/route.ts +10 -7
- package/src/app/api/openclaw/skills/route.ts +12 -4
- package/src/app/api/plugins/dependencies/route.ts +24 -0
- package/src/app/api/plugins/install/route.ts +15 -92
- package/src/app/api/plugins/route.ts +3 -26
- package/src/app/api/plugins/settings/route.ts +17 -12
- package/src/app/api/plugins/ui/route.ts +1 -0
- package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
- package/src/app/api/schedules/[id]/route.ts +38 -9
- package/src/app/api/schedules/route.ts +51 -28
- package/src/app/api/settings/route.ts +55 -17
- package/src/app/api/setup/doctor/route.ts +6 -4
- package/src/app/api/tasks/[id]/route.ts +16 -6
- package/src/app/api/tasks/bulk/route.ts +3 -3
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +135 -17
- package/src/cli/binary.test.js +142 -0
- package/src/cli/index.js +38 -11
- package/src/cli/index.test.js +195 -0
- package/src/cli/index.ts +21 -12
- package/src/cli/server-cmd.test.js +59 -0
- package/src/cli/spec.js +20 -2
- package/src/components/agents/agent-card.tsx +15 -12
- package/src/components/agents/agent-chat-list.tsx +101 -1
- package/src/components/agents/agent-list.tsx +46 -9
- package/src/components/agents/agent-sheet.tsx +456 -23
- package/src/components/agents/inspector-panel.tsx +110 -49
- package/src/components/agents/sandbox-env-panel.tsx +4 -1
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/auth/setup-wizard.tsx +970 -275
- package/src/components/chat/chat-area.tsx +70 -27
- package/src/components/chat/chat-card.tsx +6 -21
- package/src/components/chat/chat-header.tsx +263 -366
- package/src/components/chat/chat-list.tsx +62 -26
- package/src/components/chat/checkpoint-timeline.tsx +1 -1
- package/src/components/chat/message-list.tsx +145 -19
- package/src/components/chatrooms/chatroom-input.tsx +96 -33
- package/src/components/chatrooms/chatroom-list.tsx +141 -72
- package/src/components/chatrooms/chatroom-message.tsx +7 -6
- package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
- package/src/components/chatrooms/chatroom-view.tsx +422 -209
- package/src/components/chatrooms/reaction-picker.tsx +38 -33
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +217 -0
- package/src/components/gateways/gateway-sheet.tsx +567 -0
- package/src/components/home/home-view.tsx +128 -4
- package/src/components/input/chat-input.tsx +135 -86
- package/src/components/layout/app-layout.tsx +385 -194
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/memory/memory-browser.tsx +71 -6
- package/src/components/memory/memory-card.tsx +18 -0
- package/src/components/memory/memory-detail.tsx +58 -31
- package/src/components/memory/memory-sheet.tsx +32 -4
- package/src/components/plugins/plugin-list.tsx +15 -3
- package/src/components/plugins/plugin-sheet.tsx +118 -9
- package/src/components/projects/project-detail.tsx +189 -1
- package/src/components/providers/provider-list.tsx +158 -2
- package/src/components/providers/provider-sheet.tsx +81 -70
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/bottom-sheet.tsx +31 -15
- package/src/components/shared/command-palette.tsx +111 -24
- package/src/components/shared/confirm-dialog.tsx +45 -30
- package/src/components/shared/model-combobox.tsx +90 -8
- package/src/components/shared/settings/plugin-manager.tsx +20 -4
- package/src/components/shared/settings/section-capability-policy.tsx +105 -0
- package/src/components/shared/settings/section-heartbeat.tsx +88 -6
- package/src/components/shared/settings/section-orchestrator.tsx +6 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
- package/src/components/shared/settings/section-secrets.tsx +6 -6
- package/src/components/shared/settings/section-user-preferences.tsx +1 -1
- package/src/components/shared/settings/section-voice.tsx +5 -1
- package/src/components/shared/settings/section-web-search.tsx +10 -2
- package/src/components/shared/settings/settings-page.tsx +248 -47
- package/src/components/tasks/approvals-panel.tsx +211 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/usage/metrics-dashboard.tsx +74 -1
- package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +7 -7
- package/src/lib/auth.ts +17 -0
- package/src/lib/chat-streaming-state.test.ts +108 -0
- package/src/lib/chat-streaming-state.ts +108 -0
- package/src/lib/heartbeat-defaults.ts +48 -0
- package/src/lib/memory-presentation.ts +59 -0
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/provider-model-discovery-client.ts +29 -0
- package/src/lib/providers/index.ts +12 -5
- package/src/lib/runtime-loop.ts +105 -3
- package/src/lib/safe-storage.ts +6 -1
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/agent-runtime-config.test.ts +141 -0
- package/src/lib/server/agent-runtime-config.ts +277 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +264 -0
- package/src/lib/server/approvals.ts +483 -75
- package/src/lib/server/autonomy-runtime.test.ts +341 -0
- package/src/lib/server/browser-state.test.ts +118 -0
- package/src/lib/server/browser-state.ts +123 -0
- package/src/lib/server/build-llm.test.ts +44 -0
- package/src/lib/server/build-llm.ts +11 -4
- package/src/lib/server/builtin-plugins.ts +34 -0
- package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
- package/src/lib/server/chat-execution.ts +402 -125
- package/src/lib/server/chatroom-health.test.ts +26 -0
- package/src/lib/server/chatroom-health.ts +2 -3
- package/src/lib/server/chatroom-helpers.test.ts +74 -2
- package/src/lib/server/chatroom-helpers.ts +144 -11
- package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
- package/src/lib/server/connectors/discord.ts +175 -11
- package/src/lib/server/connectors/doctor.test.ts +80 -0
- package/src/lib/server/connectors/doctor.ts +116 -0
- package/src/lib/server/connectors/manager.ts +994 -130
- package/src/lib/server/connectors/policy.test.ts +222 -0
- package/src/lib/server/connectors/policy.ts +452 -0
- package/src/lib/server/connectors/slack.ts +189 -10
- package/src/lib/server/connectors/telegram.ts +65 -15
- package/src/lib/server/connectors/thread-context.test.ts +44 -0
- package/src/lib/server/connectors/thread-context.ts +72 -0
- package/src/lib/server/connectors/types.ts +41 -11
- package/src/lib/server/daemon-state.ts +62 -3
- package/src/lib/server/data-dir.ts +13 -0
- package/src/lib/server/delegation-jobs.test.ts +140 -0
- package/src/lib/server/delegation-jobs.ts +248 -0
- package/src/lib/server/document-utils.test.ts +47 -0
- package/src/lib/server/document-utils.ts +397 -0
- package/src/lib/server/eval/agent-regression.test.ts +47 -0
- package/src/lib/server/eval/agent-regression.ts +1742 -0
- package/src/lib/server/eval/runner.ts +11 -1
- package/src/lib/server/eval/store.ts +2 -1
- package/src/lib/server/heartbeat-service.ts +23 -43
- package/src/lib/server/heartbeat-source.test.ts +22 -0
- package/src/lib/server/heartbeat-source.ts +7 -0
- package/src/lib/server/identity-continuity.test.ts +77 -0
- package/src/lib/server/identity-continuity.ts +127 -0
- package/src/lib/server/mailbox-utils.ts +347 -0
- package/src/lib/server/main-agent-loop.ts +31 -964
- package/src/lib/server/memory-db.ts +4 -6
- package/src/lib/server/memory-tiers.ts +40 -0
- package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
- package/src/lib/server/openclaw-agent-resolver.ts +128 -0
- package/src/lib/server/openclaw-exec-config.ts +6 -5
- package/src/lib/server/openclaw-gateway.ts +123 -36
- package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
- package/src/lib/server/openclaw-skills-normalize.ts +136 -0
- package/src/lib/server/openclaw-sync.ts +3 -2
- package/src/lib/server/orchestrator-lg.ts +18 -8
- package/src/lib/server/orchestrator.ts +5 -4
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +215 -0
- package/src/lib/server/plugins.ts +832 -69
- package/src/lib/server/provider-health.ts +33 -3
- package/src/lib/server/provider-model-discovery.ts +481 -0
- package/src/lib/server/queue.ts +4 -21
- package/src/lib/server/runtime-settings.test.ts +119 -0
- package/src/lib/server/runtime-settings.ts +12 -92
- package/src/lib/server/schedule-normalization.ts +187 -0
- package/src/lib/server/scheduler.ts +2 -0
- package/src/lib/server/session-archive-memory.test.ts +85 -0
- package/src/lib/server/session-archive-memory.ts +230 -0
- package/src/lib/server/session-mailbox.ts +8 -18
- package/src/lib/server/session-reset-policy.test.ts +99 -0
- package/src/lib/server/session-reset-policy.ts +311 -0
- package/src/lib/server/session-run-manager.ts +33 -80
- package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
- package/src/lib/server/session-tools/calendar.ts +2 -12
- package/src/lib/server/session-tools/connector.ts +109 -8
- package/src/lib/server/session-tools/context.ts +14 -2
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +96 -34
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +406 -20
- package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
- package/src/lib/server/session-tools/discovery.ts +40 -12
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/email.ts +1 -3
- package/src/lib/server/session-tools/extract.ts +137 -0
- package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +243 -24
- package/src/lib/server/session-tools/http.ts +9 -3
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +1 -3
- package/src/lib/server/session-tools/index.ts +87 -2
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
- package/src/lib/server/session-tools/memory.ts +35 -3
- package/src/lib/server/session-tools/monitor.ts +162 -12
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +142 -4
- package/src/lib/server/session-tools/plugin-creator.ts +95 -25
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +1 -3
- package/src/lib/server/session-tools/sandbox.ts +51 -92
- package/src/lib/server/session-tools/schedule.ts +20 -10
- package/src/lib/server/session-tools/session-info.ts +58 -4
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
- package/src/lib/server/session-tools/shell.ts +2 -2
- package/src/lib/server/session-tools/subagent.ts +195 -27
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +13 -10
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +947 -108
- package/src/lib/server/storage.ts +255 -10
- package/src/lib/server/stream-agent-chat.test.ts +61 -0
- package/src/lib/server/stream-agent-chat.ts +185 -25
- package/src/lib/server/structured-extract.test.ts +72 -0
- package/src/lib/server/structured-extract.ts +373 -0
- package/src/lib/server/task-mention.test.ts +16 -2
- package/src/lib/server/task-mention.ts +61 -11
- package/src/lib/server/tool-aliases.ts +80 -12
- package/src/lib/server/tool-capability-policy.ts +7 -1
- package/src/lib/server/tool-retry.ts +2 -0
- package/src/lib/server/watch-jobs.test.ts +173 -0
- package/src/lib/server/watch-jobs.ts +532 -0
- package/src/lib/server/ws-hub.ts +5 -3
- package/src/lib/setup-defaults.ts +352 -11
- package/src/lib/tool-definitions.ts +3 -4
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +62 -1
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +43 -7
- package/src/stores/use-chat-store.ts +31 -2
- package/src/stores/use-chatroom-store.ts +153 -26
- package/src/types/index.ts +470 -44
- package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
- package/src/components/chat/new-chat-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -17
- package/src/lib/server/session-run-manager.test.ts +0 -26
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
parseMentions,
|
|
21
21
|
compactChatroomMessages,
|
|
22
22
|
buildChatroomSystemPrompt,
|
|
23
|
-
|
|
23
|
+
ensureSyntheticSession,
|
|
24
24
|
buildAgentSystemPromptForChatroom,
|
|
25
25
|
buildHistoryForAgent,
|
|
26
26
|
resolveApiKey as resolveApiKeyHelper,
|
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
import { filterHealthyChatroomAgents } from '../chatroom-health'
|
|
29
29
|
import { evaluateRoutingRules } from '../chatroom-routing'
|
|
30
30
|
import { markProviderFailure, markProviderSuccess } from '../provider-health'
|
|
31
|
+
import { syncSessionArchiveMemory } from '../session-archive-memory'
|
|
32
|
+
import { buildIdentityContinuityContext } from '../identity-continuity'
|
|
31
33
|
import { getProvider } from '@/lib/providers'
|
|
32
|
-
import type { Connector, MessageSource, Chatroom, ChatroomMessage } from '@/types'
|
|
34
|
+
import type { Agent, Connector, MessageSource, Chatroom, ChatroomMessage, Session } from '@/types'
|
|
33
35
|
import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
|
|
34
36
|
import {
|
|
35
37
|
addAllowedSender,
|
|
@@ -43,6 +45,20 @@ import {
|
|
|
43
45
|
type PairingPolicy,
|
|
44
46
|
} from './pairing'
|
|
45
47
|
import { enrichInboundMessageWithAudioTranscript } from './inbound-audio-transcription'
|
|
48
|
+
import {
|
|
49
|
+
buildConnectorConversationKey,
|
|
50
|
+
buildConnectorDoctorWarnings,
|
|
51
|
+
buildInboundDebounceKey,
|
|
52
|
+
buildInboundDedupeKey,
|
|
53
|
+
getConnectorSessionStaleness,
|
|
54
|
+
isReplyToLastOutbound,
|
|
55
|
+
mergeInboundMessages,
|
|
56
|
+
resetConnectorSessionRuntime,
|
|
57
|
+
resolveConnectorSessionPolicy,
|
|
58
|
+
shouldReplyToInboundMessage,
|
|
59
|
+
textMentionsAlias,
|
|
60
|
+
} from './policy'
|
|
61
|
+
import { buildConnectorThreadContextBlock, resolveThreadPersonaLabel } from './thread-context'
|
|
46
62
|
|
|
47
63
|
function resolveUploadPathFromUrl(rawUrl: string): string | null {
|
|
48
64
|
if (!rawUrl) return null
|
|
@@ -80,7 +96,7 @@ function parseSseDataEvents(raw: string): Array<Record<string, unknown>> {
|
|
|
80
96
|
return events
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string } | null {
|
|
99
|
+
function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string; messageId?: string } | null {
|
|
84
100
|
const raw = toolOutput.trim()
|
|
85
101
|
if (!raw) return null
|
|
86
102
|
try {
|
|
@@ -90,7 +106,8 @@ function parseConnectorToolResult(toolOutput: string): { status?: string; to?: s
|
|
|
90
106
|
const status = typeof record.status === 'string' ? String(record.status) : undefined
|
|
91
107
|
const to = typeof record.to === 'string' ? String(record.to) : undefined
|
|
92
108
|
const followUpId = typeof record.followUpId === 'string' ? String(record.followUpId) : undefined
|
|
93
|
-
|
|
109
|
+
const messageId = typeof record.messageId === 'string' ? String(record.messageId) : undefined
|
|
110
|
+
return { status, to, followUpId, messageId }
|
|
94
111
|
} catch {
|
|
95
112
|
return null
|
|
96
113
|
}
|
|
@@ -241,30 +258,41 @@ export function isNoMessage(text: string): boolean {
|
|
|
241
258
|
* Stored on globalThis to survive HMR reloads in dev mode —
|
|
242
259
|
* prevents duplicate sockets fighting for the same WhatsApp session. */
|
|
243
260
|
const globalKey = '__swarmclaw_running_connectors__' as const
|
|
244
|
-
|
|
245
|
-
|
|
261
|
+
const g = globalThis as typeof globalThis & Record<string, unknown>
|
|
262
|
+
|
|
263
|
+
function getOrInitGlobalValue<T>(key: string, factory: () => T): T {
|
|
264
|
+
const existing = g[key]
|
|
265
|
+
if (existing !== undefined) return existing as T
|
|
266
|
+
const created = factory()
|
|
267
|
+
g[key] = created
|
|
268
|
+
return created
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
type ConnectorSession = Session
|
|
272
|
+
type ConnectorAgent = Agent
|
|
273
|
+
|
|
246
274
|
const running: Map<string, ConnectorInstance> =
|
|
247
|
-
|
|
275
|
+
getOrInitGlobalValue(globalKey, () => new Map<string, ConnectorInstance>())
|
|
248
276
|
|
|
249
277
|
/** Most recent inbound channel per connector (used for proactive replies/default outbound target) */
|
|
250
278
|
const lastInboundKey = '__swarmclaw_connector_last_inbound__' as const
|
|
251
279
|
const lastInboundChannelByConnector: Map<string, string> =
|
|
252
|
-
|
|
280
|
+
getOrInitGlobalValue(lastInboundKey, () => new Map<string, string>())
|
|
253
281
|
|
|
254
282
|
/** Last inbound message timestamp per connector (for presence indicators) */
|
|
255
283
|
const lastInboundTimeKey = '__swarmclaw_connector_last_inbound_time__' as const
|
|
256
284
|
const lastInboundTimeByConnector: Map<string, number> =
|
|
257
|
-
|
|
285
|
+
getOrInitGlobalValue(lastInboundTimeKey, () => new Map<string, number>())
|
|
258
286
|
|
|
259
287
|
/** Per-connector lock to prevent concurrent start/stop operations */
|
|
260
288
|
const lockKey = '__swarmclaw_connector_locks__' as const
|
|
261
289
|
const locks: Map<string, Promise<void>> =
|
|
262
|
-
|
|
290
|
+
getOrInitGlobalValue(lockKey, () => new Map<string, Promise<void>>())
|
|
263
291
|
|
|
264
292
|
/** Generation counter per connector — used to detect stale lifecycle events after restart */
|
|
265
293
|
const genCounterKey = '__swarmclaw_connector_gen__' as const
|
|
266
294
|
const generationCounter: Map<string, number> =
|
|
267
|
-
|
|
295
|
+
getOrInitGlobalValue(genCounterKey, () => new Map<string, number>())
|
|
268
296
|
|
|
269
297
|
type ScheduledConnectorFollowup = {
|
|
270
298
|
id: string
|
|
@@ -277,7 +305,25 @@ type ScheduledConnectorFollowup = {
|
|
|
277
305
|
|
|
278
306
|
const followupKey = '__swarmclaw_connector_followups__' as const
|
|
279
307
|
const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
|
|
280
|
-
|
|
308
|
+
getOrInitGlobalValue(followupKey, () => new Map<string, ScheduledConnectorFollowup>())
|
|
309
|
+
|
|
310
|
+
const inboundDedupeKey = '__swarmclaw_connector_inbound_dedupe__' as const
|
|
311
|
+
const recentInboundByKey: Map<string, number> =
|
|
312
|
+
getOrInitGlobalValue(inboundDedupeKey, () => new Map<string, number>())
|
|
313
|
+
|
|
314
|
+
type DebouncedInboundEntry = {
|
|
315
|
+
connector: Connector
|
|
316
|
+
messages: InboundMessage[]
|
|
317
|
+
timer: ReturnType<typeof setTimeout>
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const inboundDebounceKey = '__swarmclaw_connector_inbound_debounce__' as const
|
|
321
|
+
const pendingInboundDebounce: Map<string, DebouncedInboundEntry> =
|
|
322
|
+
getOrInitGlobalValue(inboundDebounceKey, () => new Map<string, DebouncedInboundEntry>())
|
|
323
|
+
|
|
324
|
+
const followupDedupeKey = '__swarmclaw_connector_followup_dedupe__' as const
|
|
325
|
+
const scheduledFollowupByDedupe: Map<string, { id: string; sendAt: number }> =
|
|
326
|
+
getOrInitGlobalValue(followupDedupeKey, () => new Map<string, { id: string; sendAt: number }>())
|
|
281
327
|
|
|
282
328
|
/** Reconnect state per connector — tracks backoff and retry attempts for crash recovery */
|
|
283
329
|
export interface ConnectorReconnectState {
|
|
@@ -290,7 +336,7 @@ export interface ConnectorReconnectState {
|
|
|
290
336
|
|
|
291
337
|
const reconnectStateKey = '__swarmclaw_connector_reconnect_state__' as const
|
|
292
338
|
const reconnectState: Map<string, ConnectorReconnectState> =
|
|
293
|
-
|
|
339
|
+
getOrInitGlobalValue(reconnectStateKey, () => new Map<string, ConnectorReconnectState>())
|
|
294
340
|
|
|
295
341
|
const RECONNECT_INITIAL_BACKOFF_MS = 1_000
|
|
296
342
|
const RECONNECT_MAX_BACKOFF_MS = 5 * 60 * 1_000
|
|
@@ -308,10 +354,155 @@ function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType,
|
|
|
308
354
|
})
|
|
309
355
|
}
|
|
310
356
|
|
|
357
|
+
function statusReactionForPlatform(platform: string, state: 'processing' | 'sent' | 'silent'): string {
|
|
358
|
+
if (platform === 'slack') {
|
|
359
|
+
if (state === 'processing') return 'eyes'
|
|
360
|
+
if (state === 'sent') return 'white_check_mark'
|
|
361
|
+
return 'zipper_mouth_face'
|
|
362
|
+
}
|
|
363
|
+
if (state === 'processing') return '👀'
|
|
364
|
+
if (state === 'sent') return '✅'
|
|
365
|
+
return '🤐'
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function pruneTransientConnectorState(now = Date.now()): void {
|
|
369
|
+
for (const [key, seenAt] of recentInboundByKey.entries()) {
|
|
370
|
+
if (now - seenAt > 120_000) recentInboundByKey.delete(key)
|
|
371
|
+
}
|
|
372
|
+
for (const [key, entry] of scheduledFollowupByDedupe.entries()) {
|
|
373
|
+
if (entry.sendAt <= now) scheduledFollowupByDedupe.delete(key)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function rememberRecentInbound(key: string, now = Date.now(), ttlMs = 120_000): boolean {
|
|
378
|
+
pruneTransientConnectorState(now)
|
|
379
|
+
const previous = recentInboundByKey.get(key) || 0
|
|
380
|
+
if (previous && now - previous < ttlMs) return false
|
|
381
|
+
recentInboundByKey.set(key, now)
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): ConnectorSession | null {
|
|
386
|
+
if (connector.chatroomId) return null
|
|
387
|
+
const effectiveAgentId = msg.agentIdOverride || connector.agentId
|
|
388
|
+
const sessions = Object.values(loadSessions() as Record<string, ConnectorSession>)
|
|
389
|
+
const candidates = sessions.filter((session) =>
|
|
390
|
+
session?.agentId === effectiveAgentId
|
|
391
|
+
&& session?.connectorContext?.connectorId === connector.id
|
|
392
|
+
&& session?.connectorContext?.channelId === msg.channelId,
|
|
393
|
+
)
|
|
394
|
+
if (msg.threadId) {
|
|
395
|
+
const threadExact = candidates.find((session) => session?.connectorContext?.threadId === msg.threadId)
|
|
396
|
+
if (threadExact) return threadExact
|
|
397
|
+
}
|
|
398
|
+
const senderExact = candidates.find((session) => session?.connectorContext?.senderId === msg.senderId)
|
|
399
|
+
if (senderExact) return senderExact
|
|
400
|
+
return candidates[0] || null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function maybeSendStatusReaction(
|
|
404
|
+
connector: Connector,
|
|
405
|
+
msg: InboundMessage,
|
|
406
|
+
state: 'processing' | 'sent' | 'silent',
|
|
407
|
+
): Promise<void> {
|
|
408
|
+
if (!msg.messageId) return
|
|
409
|
+
const session = findDirectSessionForInbound(connector, msg)
|
|
410
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
411
|
+
if (!policy.statusReactions) return
|
|
412
|
+
const instance = running.get(connector.id)
|
|
413
|
+
if (!instance?.sendReaction) return
|
|
414
|
+
try {
|
|
415
|
+
await instance.sendReaction(msg.channelId, msg.messageId, statusReactionForPlatform(connector.platform, state))
|
|
416
|
+
} catch {
|
|
417
|
+
// Ignore reaction failures — connectors vary widely here.
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function startConnectorTypingLoop(connector: Connector, msg: InboundMessage): (() => void) | null {
|
|
422
|
+
const session = findDirectSessionForInbound(connector, msg)
|
|
423
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
424
|
+
if (!policy.typingIndicators) return null
|
|
425
|
+
const instance = running.get(connector.id)
|
|
426
|
+
if (!instance?.sendTyping) return null
|
|
427
|
+
const replyOptions = shouldReplyToInboundMessage({ msg, session, policy })
|
|
428
|
+
|
|
429
|
+
const sendTyping = () => {
|
|
430
|
+
void instance.sendTyping?.(msg.channelId, { threadId: replyOptions.threadId }).catch(() => {
|
|
431
|
+
// Best effort only.
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
sendTyping()
|
|
436
|
+
const timer = setInterval(sendTyping, 4_000)
|
|
437
|
+
timer.unref?.()
|
|
438
|
+
return () => clearInterval(timer)
|
|
439
|
+
}
|
|
440
|
+
|
|
311
441
|
type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
|
|
312
442
|
const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
|
|
313
443
|
const routeMessageHandlerRef: { current: RouteMessageHandler } =
|
|
314
|
-
|
|
444
|
+
getOrInitGlobalValue(routeHandlerKey, () => ({ current: async () => '[Error] Connector router unavailable.' }))
|
|
445
|
+
|
|
446
|
+
async function flushDebouncedInbound(key: string): Promise<void> {
|
|
447
|
+
const entry = pendingInboundDebounce.get(key)
|
|
448
|
+
if (!entry) return
|
|
449
|
+
pendingInboundDebounce.delete(key)
|
|
450
|
+
clearTimeout(entry.timer)
|
|
451
|
+
const merged = mergeInboundMessages(entry.messages)
|
|
452
|
+
const response = await routeMessageHandlerRef.current(entry.connector, merged)
|
|
453
|
+
if (isNoMessage(response)) {
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
const replyOptions = getConnectorReplySendOptions({ connectorId: entry.connector.id, inbound: merged })
|
|
457
|
+
const session = findDirectSessionForInbound(entry.connector, merged)
|
|
458
|
+
await sendConnectorMessage({
|
|
459
|
+
connectorId: entry.connector.id,
|
|
460
|
+
channelId: merged.channelId,
|
|
461
|
+
text: response,
|
|
462
|
+
sessionId: session?.id,
|
|
463
|
+
replyToMessageId: replyOptions.replyToMessageId,
|
|
464
|
+
threadId: replyOptions.threadId,
|
|
465
|
+
})
|
|
466
|
+
await maybeSendStatusReaction(entry.connector, merged, 'sent')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function routeOrDebounceInbound(connector: Connector, msg: InboundMessage): Promise<string> {
|
|
470
|
+
const dedupeKey = buildInboundDedupeKey(connector, msg)
|
|
471
|
+
const dedupeTtlMs = dedupeKey.startsWith('msg:') ? 120_000 : 15_000
|
|
472
|
+
if (!rememberRecentInbound(dedupeKey, Date.now(), dedupeTtlMs)) return NO_MESSAGE_SENTINEL
|
|
473
|
+
|
|
474
|
+
const session = findDirectSessionForInbound(connector, msg)
|
|
475
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
476
|
+
if (policy.inboundDebounceMs <= 0) {
|
|
477
|
+
return routeMessageHandlerRef.current(connector, msg)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const debounceKey = buildInboundDebounceKey(connector, msg)
|
|
481
|
+
const pending = pendingInboundDebounce.get(debounceKey)
|
|
482
|
+
if (pending) {
|
|
483
|
+
pending.messages.push(msg)
|
|
484
|
+
clearTimeout(pending.timer)
|
|
485
|
+
pending.timer = setTimeout(() => {
|
|
486
|
+
void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
|
|
487
|
+
console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
488
|
+
})
|
|
489
|
+
}, policy.inboundDebounceMs)
|
|
490
|
+
pending.timer.unref?.()
|
|
491
|
+
} else {
|
|
492
|
+
const timer = setTimeout(() => {
|
|
493
|
+
void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
|
|
494
|
+
console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
495
|
+
})
|
|
496
|
+
}, policy.inboundDebounceMs)
|
|
497
|
+
timer.unref?.()
|
|
498
|
+
pendingInboundDebounce.set(debounceKey, {
|
|
499
|
+
connector,
|
|
500
|
+
messages: [msg],
|
|
501
|
+
timer,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
return NO_MESSAGE_SENTINEL
|
|
505
|
+
}
|
|
315
506
|
|
|
316
507
|
function dispatchInboundConnectorMessage(
|
|
317
508
|
connectorId: string,
|
|
@@ -320,7 +511,7 @@ function dispatchInboundConnectorMessage(
|
|
|
320
511
|
): Promise<string> {
|
|
321
512
|
const connectors = loadConnectors()
|
|
322
513
|
const currentConnector = connectors[connectorId] as Connector | undefined
|
|
323
|
-
return
|
|
514
|
+
return routeOrDebounceInbound(currentConnector ?? fallbackConnector, msg)
|
|
324
515
|
}
|
|
325
516
|
|
|
326
517
|
/** Get the current generation number for a connector (0 if never started) */
|
|
@@ -404,7 +595,17 @@ export function formatInboundUserText(msg: InboundMessage): string {
|
|
|
404
595
|
return lines.join('\n').trim()
|
|
405
596
|
}
|
|
406
597
|
|
|
407
|
-
type ConnectorCommandName =
|
|
598
|
+
type ConnectorCommandName =
|
|
599
|
+
| 'help'
|
|
600
|
+
| 'status'
|
|
601
|
+
| 'new'
|
|
602
|
+
| 'reset'
|
|
603
|
+
| 'compact'
|
|
604
|
+
| 'think'
|
|
605
|
+
| 'pair'
|
|
606
|
+
| 'session'
|
|
607
|
+
| 'focus'
|
|
608
|
+
| 'doctor'
|
|
408
609
|
|
|
409
610
|
interface ParsedConnectorCommand {
|
|
410
611
|
name: ConnectorCommandName
|
|
@@ -425,22 +626,318 @@ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
|
|
|
425
626
|
case 'compact':
|
|
426
627
|
case 'think':
|
|
427
628
|
case 'pair':
|
|
629
|
+
case 'session':
|
|
630
|
+
case 'focus':
|
|
631
|
+
case 'doctor':
|
|
428
632
|
return { name, args } as ParsedConnectorCommand
|
|
429
633
|
default:
|
|
430
634
|
return null
|
|
431
635
|
}
|
|
432
636
|
}
|
|
433
637
|
|
|
434
|
-
|
|
435
|
-
|
|
638
|
+
function persistSessionRecord(session: ConnectorSession): void {
|
|
639
|
+
const sessions = loadSessions()
|
|
640
|
+
sessions[session.id] = session
|
|
641
|
+
saveSessions(sessions)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function updateSessionConnectorContext(session: ConnectorSession, connector: Connector, msg: InboundMessage, sessionKey: string): void {
|
|
645
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
646
|
+
session.connectorContext = {
|
|
647
|
+
...(session.connectorContext || {}),
|
|
648
|
+
connectorId: connector.id,
|
|
649
|
+
platform: connector.platform,
|
|
650
|
+
channelId: msg.channelId,
|
|
651
|
+
senderId: msg.senderId,
|
|
652
|
+
senderName: msg.senderName,
|
|
653
|
+
sessionKey,
|
|
654
|
+
peerKey: msg.senderId,
|
|
655
|
+
scope: policy.scope,
|
|
656
|
+
replyMode: policy.replyMode,
|
|
657
|
+
threadBinding: policy.threadBinding,
|
|
658
|
+
groupPolicy: policy.groupPolicy,
|
|
659
|
+
threadId: msg.threadId || session.connectorContext?.threadId || null,
|
|
660
|
+
threadTitle: msg.threadTitle || session.connectorContext?.threadTitle || null,
|
|
661
|
+
threadPersonaLabel: resolveThreadPersonaLabel(msg) || session.connectorContext?.threadPersonaLabel || null,
|
|
662
|
+
threadParentChannelId: msg.threadParentChannelId || session.connectorContext?.threadParentChannelId || null,
|
|
663
|
+
threadParentChannelName: msg.threadParentChannelName || session.connectorContext?.threadParentChannelName || null,
|
|
664
|
+
isGroup: !!msg.isGroup,
|
|
665
|
+
lastInboundAt: Date.now(),
|
|
666
|
+
lastInboundMessageId: msg.messageId || null,
|
|
667
|
+
lastInboundReplyToMessageId: msg.replyToMessageId || null,
|
|
668
|
+
lastInboundThreadId: msg.threadId || null,
|
|
669
|
+
lastOutboundAt: session.connectorContext?.lastOutboundAt ?? null,
|
|
670
|
+
lastOutboundMessageId: session.connectorContext?.lastOutboundMessageId ?? null,
|
|
671
|
+
lastResetAt: session.connectorContext?.lastResetAt ?? null,
|
|
672
|
+
lastResetReason: session.connectorContext?.lastResetReason ?? null,
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function describeSessionControls(session: ConnectorSession, connector: Connector, msg: InboundMessage): string {
|
|
677
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
678
|
+
const context = session.connectorContext || {}
|
|
679
|
+
const sessionAgeSec = Math.max(0, Math.round((Date.now() - (session.createdAt || Date.now())) / 1000))
|
|
680
|
+
const idleSec = Math.max(0, Math.round((Date.now() - (session.lastActiveAt || Date.now())) / 1000))
|
|
681
|
+
return [
|
|
682
|
+
`Session controls for ${connector.platform}/${connector.name}:`,
|
|
683
|
+
`- Session: ${session.id}`,
|
|
684
|
+
`- Scope: ${policy.scope}`,
|
|
685
|
+
`- Reply mode: ${policy.replyMode}`,
|
|
686
|
+
`- Thread binding: ${policy.threadBinding}`,
|
|
687
|
+
`- Group policy: ${policy.groupPolicy}`,
|
|
688
|
+
`- Reset mode: ${policy.resetMode}`,
|
|
689
|
+
`- Idle timeout: ${policy.idleTimeoutSec ?? 0}s`,
|
|
690
|
+
`- Max age: ${policy.maxAgeSec ?? 0}s`,
|
|
691
|
+
`- Daily reset: ${policy.dailyResetAt || 'off'}`,
|
|
692
|
+
`- Reset timezone: ${policy.resetTimezone || 'local'}`,
|
|
693
|
+
`- Debounce: ${policy.inboundDebounceMs}ms`,
|
|
694
|
+
`- Typing indicators: ${policy.typingIndicators ? 'on' : 'off'}`,
|
|
695
|
+
`- Thinking: ${policy.thinkingLevel || session.thinkingLevel || 'inherit'}`,
|
|
696
|
+
`- Model: ${session.provider}/${session.model}`,
|
|
697
|
+
`- Last outbound message: ${context.lastOutboundMessageId || 'none'}`,
|
|
698
|
+
`- Thread: ${context.threadId || 'none'}`,
|
|
699
|
+
`- Thread title: ${context.threadTitle || 'none'}`,
|
|
700
|
+
`- Thread persona: ${context.threadPersonaLabel || 'none'}`,
|
|
701
|
+
`- Session age: ${sessionAgeSec}s`,
|
|
702
|
+
`- Idle for: ${idleSec}s`,
|
|
703
|
+
].join('\n')
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function normalizeSessionSettingKey(raw: string): string {
|
|
707
|
+
return raw.trim().toLowerCase().replace(/[_-]+/g, '')
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function applySessionSetting(session: ConnectorSession, keyRaw: string, valueRaw: string, msg: InboundMessage): string {
|
|
711
|
+
const key = normalizeSessionSettingKey(keyRaw)
|
|
712
|
+
const value = valueRaw.trim()
|
|
713
|
+
const asInt = () => {
|
|
714
|
+
const parsed = Number.parseInt(value, 10)
|
|
715
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
716
|
+
throw new Error(`Invalid numeric value for ${keyRaw}: ${valueRaw}`)
|
|
717
|
+
}
|
|
718
|
+
return parsed
|
|
719
|
+
}
|
|
720
|
+
const asEnum = <T extends string>(allowed: readonly T[], label: string): T | null => {
|
|
721
|
+
if (!value) return null
|
|
722
|
+
const normalized = value.toLowerCase()
|
|
723
|
+
if ((allowed as readonly string[]).includes(normalized)) return normalized as T
|
|
724
|
+
throw new Error(`Invalid ${label}. Use one of: ${allowed.join(', ')}.`)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
switch (key) {
|
|
728
|
+
case 'think':
|
|
729
|
+
case 'thinkinglevel':
|
|
730
|
+
session.connectorThinkLevel = asEnum(['minimal', 'low', 'medium', 'high'] as const, '/think level')
|
|
731
|
+
return `Connector thinking level set to ${session.connectorThinkLevel || 'inherit'}.`
|
|
732
|
+
case 'reply':
|
|
733
|
+
case 'replymode':
|
|
734
|
+
session.connectorReplyMode = asEnum(['off', 'first', 'all'] as const, 'reply mode')
|
|
735
|
+
return `Reply mode set to ${session.connectorReplyMode || 'inherit'}.`
|
|
736
|
+
case 'scope':
|
|
737
|
+
case 'sessionscope':
|
|
738
|
+
session.connectorSessionScope = asEnum(['main', 'channel', 'peer', 'channel-peer', 'thread'] as const, 'session scope')
|
|
739
|
+
return `Session scope set to ${session.connectorSessionScope || 'inherit'}.`
|
|
740
|
+
case 'thread':
|
|
741
|
+
case 'threadbinding':
|
|
742
|
+
session.connectorThreadBinding = asEnum(['off', 'prefer', 'strict'] as const, 'thread binding')
|
|
743
|
+
if (!value) {
|
|
744
|
+
session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
|
|
745
|
+
} else if (session.connectorThreadBinding === 'strict' && msg.threadId) {
|
|
746
|
+
session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
|
|
747
|
+
}
|
|
748
|
+
return `Thread binding set to ${session.connectorThreadBinding || 'inherit'}.`
|
|
749
|
+
case 'group':
|
|
750
|
+
case 'grouppolicy':
|
|
751
|
+
session.connectorGroupPolicy = asEnum(['open', 'mention', 'reply-or-mention', 'disabled'] as const, 'group policy')
|
|
752
|
+
return `Group policy set to ${session.connectorGroupPolicy || 'inherit'}.`
|
|
753
|
+
case 'idle':
|
|
754
|
+
case 'idletimeout':
|
|
755
|
+
session.connectorIdleTimeoutSec = asInt()
|
|
756
|
+
return `Idle timeout set to ${session.connectorIdleTimeoutSec}s.`
|
|
757
|
+
case 'maxage':
|
|
758
|
+
session.connectorMaxAgeSec = asInt()
|
|
759
|
+
return `Max age set to ${session.connectorMaxAgeSec}s.`
|
|
760
|
+
case 'reset':
|
|
761
|
+
case 'resetmode': {
|
|
762
|
+
const normalized = value.toLowerCase()
|
|
763
|
+
if (!value) {
|
|
764
|
+
session.sessionResetMode = null
|
|
765
|
+
return 'Reset mode set to inherit.'
|
|
766
|
+
}
|
|
767
|
+
if (normalized !== 'idle' && normalized !== 'daily') {
|
|
768
|
+
throw new Error('Reset mode must be "idle" or "daily".')
|
|
769
|
+
}
|
|
770
|
+
session.sessionResetMode = normalized
|
|
771
|
+
return `Reset mode set to ${session.sessionResetMode}.`
|
|
772
|
+
}
|
|
773
|
+
case 'daily':
|
|
774
|
+
case 'dailyreset':
|
|
775
|
+
case 'dailyresetat':
|
|
776
|
+
if (!value) {
|
|
777
|
+
session.sessionDailyResetAt = null
|
|
778
|
+
return 'Daily reset time cleared.'
|
|
779
|
+
}
|
|
780
|
+
if (!/^\d{1,2}:\d{2}$/.test(value)) {
|
|
781
|
+
throw new Error('Daily reset time must be in HH:MM format.')
|
|
782
|
+
}
|
|
783
|
+
session.sessionDailyResetAt = value
|
|
784
|
+
return `Daily reset time set to ${session.sessionDailyResetAt}.`
|
|
785
|
+
case 'timezone':
|
|
786
|
+
case 'resettimezone':
|
|
787
|
+
session.sessionResetTimezone = value || null
|
|
788
|
+
return `Reset timezone set to ${session.sessionResetTimezone || 'inherit/local'}.`
|
|
789
|
+
case 'model':
|
|
790
|
+
session.model = value
|
|
791
|
+
return `Model set to ${session.model}.`
|
|
792
|
+
case 'provider': {
|
|
793
|
+
const provider = getProvider(value)
|
|
794
|
+
if (!provider) {
|
|
795
|
+
throw new Error(`Unknown provider "${value}".`)
|
|
796
|
+
}
|
|
797
|
+
session.provider = provider.id as Session['provider']
|
|
798
|
+
session.apiEndpoint = provider.defaultEndpoint || session.apiEndpoint || null
|
|
799
|
+
return `Provider set to ${session.provider}.`
|
|
800
|
+
}
|
|
801
|
+
default:
|
|
802
|
+
throw new Error(`Unknown session setting "${keyRaw}".`)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function evaluateGroupPolicy(params: {
|
|
807
|
+
connector: Connector
|
|
808
|
+
msg: InboundMessage
|
|
809
|
+
session?: ConnectorSession | null
|
|
810
|
+
aliases: string[]
|
|
811
|
+
}): { allowed: boolean; reason: string } {
|
|
812
|
+
const { connector, msg, session, aliases } = params
|
|
813
|
+
if (!msg.isGroup) return { allowed: true, reason: 'dm' }
|
|
814
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
815
|
+
if (policy.groupPolicy === 'open') return { allowed: true, reason: 'open' }
|
|
816
|
+
if (policy.groupPolicy === 'disabled') return { allowed: false, reason: 'disabled' }
|
|
817
|
+
const mentioned = !!msg.mentionsBot || textMentionsAlias(msg.text || '', aliases)
|
|
818
|
+
const replied = isReplyToLastOutbound(msg, session)
|
|
819
|
+
if (policy.groupPolicy === 'mention') {
|
|
820
|
+
return { allowed: mentioned, reason: mentioned ? 'mentioned' : 'mention_required' }
|
|
821
|
+
}
|
|
822
|
+
const allowed = mentioned || replied
|
|
823
|
+
return { allowed, reason: allowed ? (mentioned ? 'mentioned' : 'reply') : 'reply_or_mention_required' }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function applyConnectorRuntimeDefaults(session: ConnectorSession, defaults: {
|
|
827
|
+
provider: Session['provider']
|
|
828
|
+
model: string
|
|
829
|
+
apiEndpoint: string | null
|
|
830
|
+
thinkingLevel: Session['connectorThinkLevel']
|
|
831
|
+
}): void {
|
|
832
|
+
session.provider = defaults.provider
|
|
833
|
+
session.model = defaults.model
|
|
834
|
+
session.apiEndpoint = defaults.apiEndpoint
|
|
835
|
+
session.connectorThinkLevel = defaults.thinkingLevel
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function resolveDirectSession(params: {
|
|
839
|
+
connector: Connector
|
|
840
|
+
msg: InboundMessage
|
|
841
|
+
agent: ConnectorAgent
|
|
842
|
+
}): { session: ConnectorSession; sessionKey: string; wasCreated: boolean; staleReason?: string | null; clearedMessages?: number } {
|
|
843
|
+
const { connector, msg, agent } = params
|
|
844
|
+
const policySeed = resolveConnectorSessionPolicy(connector, msg)
|
|
845
|
+
const providerInfo = policySeed.providerOverride ? getProvider(policySeed.providerOverride) : null
|
|
846
|
+
const defaultProvider: Session['provider'] = providerInfo?.id || (agent.provider === 'claude-cli' ? 'anthropic' : agent.provider)
|
|
847
|
+
const defaultModel = policySeed.modelOverride || agent.model
|
|
848
|
+
const defaultApiEndpoint = agent.apiEndpoint || providerInfo?.defaultEndpoint || null
|
|
849
|
+
const runtimeDefaults = {
|
|
850
|
+
provider: defaultProvider,
|
|
851
|
+
model: defaultModel,
|
|
852
|
+
apiEndpoint: defaultApiEndpoint,
|
|
853
|
+
thinkingLevel: policySeed.thinkingLevel || null,
|
|
854
|
+
}
|
|
855
|
+
const sessionKey = buildConnectorConversationKey({
|
|
856
|
+
connector,
|
|
857
|
+
msg,
|
|
858
|
+
agentId: agent.id,
|
|
859
|
+
policy: policySeed,
|
|
860
|
+
})
|
|
861
|
+
const sessions = loadSessions()
|
|
862
|
+
let session = Object.values(sessions as Record<string, ConnectorSession>).find((item) => item?.name === sessionKey)
|
|
863
|
+
let wasCreated = false
|
|
864
|
+
if (!session) {
|
|
865
|
+
const id = genId()
|
|
866
|
+
session = {
|
|
867
|
+
id,
|
|
868
|
+
name: sessionKey,
|
|
869
|
+
cwd: WORKSPACE_DIR,
|
|
870
|
+
user: 'connector',
|
|
871
|
+
provider: defaultProvider,
|
|
872
|
+
model: defaultModel,
|
|
873
|
+
credentialId: agent.credentialId || null,
|
|
874
|
+
fallbackCredentialIds: Array.isArray(agent.fallbackCredentialIds) ? [...agent.fallbackCredentialIds] : [],
|
|
875
|
+
apiEndpoint: defaultApiEndpoint,
|
|
876
|
+
claudeSessionId: null,
|
|
877
|
+
codexThreadId: null,
|
|
878
|
+
opencodeSessionId: null,
|
|
879
|
+
delegateResumeIds: {
|
|
880
|
+
claudeCode: null,
|
|
881
|
+
codex: null,
|
|
882
|
+
opencode: null,
|
|
883
|
+
gemini: null,
|
|
884
|
+
},
|
|
885
|
+
messages: [],
|
|
886
|
+
createdAt: Date.now(),
|
|
887
|
+
lastActiveAt: Date.now(),
|
|
888
|
+
sessionType: 'human' as const,
|
|
889
|
+
agentId: agent.id,
|
|
890
|
+
plugins: agent.plugins || agent.tools || [],
|
|
891
|
+
thinkingLevel: agent.thinkingLevel || null,
|
|
892
|
+
connectorThinkLevel: policySeed.thinkingLevel || null,
|
|
893
|
+
}
|
|
894
|
+
wasCreated = true
|
|
895
|
+
}
|
|
896
|
+
session.name = sessionKey
|
|
897
|
+
session.agentId = agent.id
|
|
898
|
+
session.plugins = Array.isArray(session.plugins) ? session.plugins : (agent.plugins || agent.tools || [])
|
|
899
|
+
if (!session.provider) session.provider = defaultProvider
|
|
900
|
+
if (!session.model) session.model = defaultModel
|
|
901
|
+
if (session.credentialId === undefined) session.credentialId = agent.credentialId || null
|
|
902
|
+
if (!Array.isArray(session.fallbackCredentialIds) && Array.isArray(agent.fallbackCredentialIds)) {
|
|
903
|
+
session.fallbackCredentialIds = [...agent.fallbackCredentialIds]
|
|
904
|
+
}
|
|
905
|
+
if (session.apiEndpoint === undefined || session.apiEndpoint === null) session.apiEndpoint = defaultApiEndpoint
|
|
906
|
+
if ((session.connectorThinkLevel === undefined || session.connectorThinkLevel === null) && policySeed.thinkingLevel) {
|
|
907
|
+
session.connectorThinkLevel = policySeed.thinkingLevel
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
911
|
+
const staleness = getConnectorSessionStaleness(session, policy)
|
|
912
|
+
let clearedMessages = 0
|
|
913
|
+
if (staleness.stale) {
|
|
914
|
+
try { syncSessionArchiveMemory(session, { agent }) } catch { /* archive sync is best-effort */ }
|
|
915
|
+
clearedMessages = resetConnectorSessionRuntime(session, staleness.reason || 'session_refresh')
|
|
916
|
+
applyConnectorRuntimeDefaults(session, {
|
|
917
|
+
...runtimeDefaults,
|
|
918
|
+
thinkingLevel: policySeed.thinkingLevel || session.connectorThinkLevel || null,
|
|
919
|
+
})
|
|
920
|
+
}
|
|
921
|
+
updateSessionConnectorContext(session, connector, msg, sessionKey)
|
|
922
|
+
sessions[session.id] = session
|
|
923
|
+
saveSessions(sessions)
|
|
924
|
+
return {
|
|
925
|
+
session,
|
|
926
|
+
sessionKey,
|
|
927
|
+
wasCreated,
|
|
928
|
+
staleReason: staleness.reason || null,
|
|
929
|
+
clearedMessages,
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function pushSessionMessage(session: ConnectorSession, role: 'user' | 'assistant', text: string): void {
|
|
436
934
|
if (!text.trim()) return
|
|
437
935
|
if (!Array.isArray(session.messages)) session.messages = []
|
|
438
936
|
session.messages.push({ role, text: text.trim(), time: Date.now() })
|
|
439
937
|
session.lastActiveAt = Date.now()
|
|
440
938
|
}
|
|
441
939
|
|
|
442
|
-
|
|
443
|
-
function persistSession(session: Record<string, any>): void {
|
|
940
|
+
function persistSession(session: ConnectorSession): void {
|
|
444
941
|
const sessions = loadSessions()
|
|
445
942
|
sessions[session.id] = session
|
|
446
943
|
saveSessions(sessions)
|
|
@@ -594,8 +1091,7 @@ function enforceInboundAccessPolicy(connector: Connector, msg: InboundMessage):
|
|
|
594
1091
|
async function handleConnectorCommand(params: {
|
|
595
1092
|
command: ParsedConnectorCommand
|
|
596
1093
|
connector: Connector
|
|
597
|
-
|
|
598
|
-
session: Record<string, any>
|
|
1094
|
+
session: ConnectorSession
|
|
599
1095
|
msg: InboundMessage
|
|
600
1096
|
agentName: string
|
|
601
1097
|
}): Promise<string> {
|
|
@@ -609,6 +1105,10 @@ async function handleConnectorCommand(params: {
|
|
|
609
1105
|
'/new or /reset — Clear this connector conversation thread',
|
|
610
1106
|
'/compact [keepLastN] — Summarize older history and keep recent messages (default 10)',
|
|
611
1107
|
'/think <minimal|low|medium|high> — Set connector thread reasoning guidance',
|
|
1108
|
+
'/session — Show session controls',
|
|
1109
|
+
'/session set <scope|reply|thread|group|idle|maxAge|resetMode|dailyResetAt|timezone|think|model|provider> <value> — Patch this connector session',
|
|
1110
|
+
'/focus here|clear — Bind or clear focus on the current thread/topic',
|
|
1111
|
+
'/doctor — Show autonomy and safety warnings for this connector/session',
|
|
612
1112
|
'/pair — Pairing/access controls (status, request, list, approve, allow)',
|
|
613
1113
|
'/help — Show this list',
|
|
614
1114
|
].join('\n')
|
|
@@ -619,6 +1119,7 @@ async function handleConnectorCommand(params: {
|
|
|
619
1119
|
}
|
|
620
1120
|
|
|
621
1121
|
if (command.name === 'status') {
|
|
1122
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
622
1123
|
const all = Array.isArray(session.messages) ? session.messages : []
|
|
623
1124
|
const userCount = all.filter((m: { role?: string }) => m?.role === 'user').length
|
|
624
1125
|
const assistantCount = all.filter((m: { role?: string }) => m?.role === 'assistant').length
|
|
@@ -632,6 +1133,9 @@ async function handleConnectorCommand(params: {
|
|
|
632
1133
|
`- Tools enabled: ${toolsCount}`,
|
|
633
1134
|
`- Channel: ${msg.channelName || msg.channelId}`,
|
|
634
1135
|
`- Last active: ${new Date(session.lastActiveAt || session.createdAt || Date.now()).toLocaleString()}`,
|
|
1136
|
+
`- Reset mode: ${policy.resetMode}`,
|
|
1137
|
+
`- Reply mode: ${policy.replyMode}`,
|
|
1138
|
+
`- Scope: ${policy.scope}`,
|
|
635
1139
|
].join('\n')
|
|
636
1140
|
pushSessionMessage(session, 'user', inboundText)
|
|
637
1141
|
pushSessionMessage(session, 'assistant', statusText)
|
|
@@ -640,13 +1144,18 @@ async function handleConnectorCommand(params: {
|
|
|
640
1144
|
}
|
|
641
1145
|
|
|
642
1146
|
if (command.name === 'new' || command.name === 'reset') {
|
|
643
|
-
const
|
|
644
|
-
session
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
session
|
|
649
|
-
|
|
1147
|
+
const agent = session.agentId ? (loadAgents() as Record<string, ConnectorAgent>)[session.agentId] : undefined
|
|
1148
|
+
try { syncSessionArchiveMemory(session, { agent }) } catch { /* best effort */ }
|
|
1149
|
+
const cleared = resetConnectorSessionRuntime(session, 'manual_reset')
|
|
1150
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
1151
|
+
const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
|
|
1152
|
+
applyConnectorRuntimeDefaults(session, {
|
|
1153
|
+
provider: providerInfo?.id || session.provider,
|
|
1154
|
+
model: policy.modelOverride || session.model,
|
|
1155
|
+
apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
|
|
1156
|
+
thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
|
|
1157
|
+
})
|
|
1158
|
+
updateSessionConnectorContext(session, connector, msg, session.name || session.id)
|
|
650
1159
|
persistSession(session)
|
|
651
1160
|
return `Reset complete for ${connector.platform} channel thread. Cleared ${cleared} message(s).`
|
|
652
1161
|
}
|
|
@@ -681,10 +1190,11 @@ async function handleConnectorCommand(params: {
|
|
|
681
1190
|
|
|
682
1191
|
if (command.name === 'think') {
|
|
683
1192
|
const requested = command.args.trim().toLowerCase()
|
|
684
|
-
const allowed = new Set(['minimal', 'low', 'medium', 'high'])
|
|
1193
|
+
const allowed = new Set(['minimal', 'low', 'medium', 'high'] as const)
|
|
685
1194
|
if (!requested) {
|
|
686
|
-
const
|
|
687
|
-
|
|
1195
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
1196
|
+
const current = typeof policy.thinkingLevel === 'string' && allowed.has(policy.thinkingLevel)
|
|
1197
|
+
? policy.thinkingLevel
|
|
688
1198
|
: 'medium'
|
|
689
1199
|
const text = `Current /think level: ${current}. Usage: /think <minimal|low|medium|high>.`
|
|
690
1200
|
pushSessionMessage(session, 'user', inboundText)
|
|
@@ -692,7 +1202,12 @@ async function handleConnectorCommand(params: {
|
|
|
692
1202
|
persistSession(session)
|
|
693
1203
|
return text
|
|
694
1204
|
}
|
|
695
|
-
if (
|
|
1205
|
+
if (
|
|
1206
|
+
requested !== 'minimal'
|
|
1207
|
+
&& requested !== 'low'
|
|
1208
|
+
&& requested !== 'medium'
|
|
1209
|
+
&& requested !== 'high'
|
|
1210
|
+
) {
|
|
696
1211
|
const text = 'Invalid /think level. Use one of: minimal, low, medium, high.'
|
|
697
1212
|
pushSessionMessage(session, 'user', inboundText)
|
|
698
1213
|
pushSessionMessage(session, 'assistant', text)
|
|
@@ -708,6 +1223,78 @@ async function handleConnectorCommand(params: {
|
|
|
708
1223
|
return text
|
|
709
1224
|
}
|
|
710
1225
|
|
|
1226
|
+
if (command.name === 'doctor') {
|
|
1227
|
+
const warnings = buildConnectorDoctorWarnings({ connector, msg, session })
|
|
1228
|
+
const text = warnings.length
|
|
1229
|
+
? ['Connector doctor:', ...warnings.map((item) => `- ${item}`)].join('\n')
|
|
1230
|
+
: 'Connector doctor: no obvious autonomy or safety issues detected.'
|
|
1231
|
+
pushSessionMessage(session, 'user', inboundText)
|
|
1232
|
+
pushSessionMessage(session, 'assistant', text)
|
|
1233
|
+
persistSession(session)
|
|
1234
|
+
return text
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (command.name === 'session') {
|
|
1238
|
+
const parts = command.args.split(/\s+/).map((item) => item.trim()).filter(Boolean)
|
|
1239
|
+
if (!parts.length || parts[0].toLowerCase() === 'show' || parts[0].toLowerCase() === 'status') {
|
|
1240
|
+
const text = describeSessionControls(session, connector, msg)
|
|
1241
|
+
pushSessionMessage(session, 'user', inboundText)
|
|
1242
|
+
pushSessionMessage(session, 'assistant', text)
|
|
1243
|
+
persistSession(session)
|
|
1244
|
+
return text
|
|
1245
|
+
}
|
|
1246
|
+
if (parts[0].toLowerCase() === 'reset') {
|
|
1247
|
+
const agent = session.agentId ? (loadAgents() as Record<string, ConnectorAgent>)[session.agentId] : undefined
|
|
1248
|
+
try { syncSessionArchiveMemory(session, { agent }) } catch { /* best effort */ }
|
|
1249
|
+
const cleared = resetConnectorSessionRuntime(session, 'manual_reset')
|
|
1250
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
1251
|
+
const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
|
|
1252
|
+
applyConnectorRuntimeDefaults(session, {
|
|
1253
|
+
provider: providerInfo?.id || session.provider,
|
|
1254
|
+
model: policy.modelOverride || session.model,
|
|
1255
|
+
apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
|
|
1256
|
+
thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
|
|
1257
|
+
})
|
|
1258
|
+
updateSessionConnectorContext(session, connector, msg, session.name || session.id)
|
|
1259
|
+
persistSession(session)
|
|
1260
|
+
return `Connector session reset. Cleared ${cleared} message(s).`
|
|
1261
|
+
}
|
|
1262
|
+
if (parts[0].toLowerCase() === 'set') {
|
|
1263
|
+
const key = parts[1] || ''
|
|
1264
|
+
const value = parts.slice(2).join(' ').trim()
|
|
1265
|
+
if (!key) return 'Usage: /session set <scope|reply|thread|group|idle|maxAge|resetMode|dailyResetAt|timezone|think|model|provider> <value>'
|
|
1266
|
+
try {
|
|
1267
|
+
const text = applySessionSetting(session, key, value, msg)
|
|
1268
|
+
updateSessionConnectorContext(session, connector, msg, session.name || session.id)
|
|
1269
|
+
persistSession(session)
|
|
1270
|
+
return text
|
|
1271
|
+
} catch (err: unknown) {
|
|
1272
|
+
return err instanceof Error ? err.message : String(err)
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return 'Usage: /session, /session show, /session set <key> <value>, /session reset'
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (command.name === 'focus') {
|
|
1279
|
+
const subcommand = command.args.trim().toLowerCase()
|
|
1280
|
+
if (subcommand === 'clear') {
|
|
1281
|
+
session.connectorThreadBinding = null
|
|
1282
|
+
session.connectorSessionScope = null
|
|
1283
|
+
session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
|
|
1284
|
+
persistSession(session)
|
|
1285
|
+
return 'Cleared connector thread focus.'
|
|
1286
|
+
}
|
|
1287
|
+
if (!msg.threadId) {
|
|
1288
|
+
return 'Focus can only be set from a threaded or topic-bound message.'
|
|
1289
|
+
}
|
|
1290
|
+
session.connectorThreadBinding = 'strict'
|
|
1291
|
+
session.connectorSessionScope = 'thread'
|
|
1292
|
+
session.connectorReplyMode = session.connectorReplyMode || 'all'
|
|
1293
|
+
session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
|
|
1294
|
+
persistSession(session)
|
|
1295
|
+
return `Focused this connector session on thread ${msg.threadId}.`
|
|
1296
|
+
}
|
|
1297
|
+
|
|
711
1298
|
return 'Unknown command.'
|
|
712
1299
|
}
|
|
713
1300
|
|
|
@@ -721,6 +1308,9 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
721
1308
|
if (!chatroom) return '[Error] Chatroom not found.'
|
|
722
1309
|
|
|
723
1310
|
const agents = loadAgents()
|
|
1311
|
+
const chatroomAgentAliases = chatroom.agentIds
|
|
1312
|
+
.map((agentId) => agents[agentId]?.name)
|
|
1313
|
+
.filter((name): name is string => typeof name === 'string' && !!name.trim())
|
|
724
1314
|
const preferredCredentialId = (() => {
|
|
725
1315
|
if (connector.agentId && agents[connector.agentId]?.credentialId) {
|
|
726
1316
|
return agents[connector.agentId].credentialId as string
|
|
@@ -735,6 +1325,16 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
735
1325
|
msg,
|
|
736
1326
|
preferredCredentialId,
|
|
737
1327
|
})
|
|
1328
|
+
const groupGate = evaluateGroupPolicy({
|
|
1329
|
+
connector,
|
|
1330
|
+
msg,
|
|
1331
|
+
aliases: [connector.name, ...chatroomAgentAliases],
|
|
1332
|
+
})
|
|
1333
|
+
if (!groupGate.allowed) return NO_MESSAGE_SENTINEL
|
|
1334
|
+
|
|
1335
|
+
await maybeSendStatusReaction(connector, msg, 'processing')
|
|
1336
|
+
const stopTyping = startConnectorTypingLoop(connector, msg)
|
|
1337
|
+
try {
|
|
738
1338
|
|
|
739
1339
|
const source: MessageSource = {
|
|
740
1340
|
platform: connector.platform,
|
|
@@ -743,10 +1343,14 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
743
1343
|
channelId: msg.channelId,
|
|
744
1344
|
senderId: msg.senderId,
|
|
745
1345
|
senderName: msg.senderName,
|
|
1346
|
+
messageId: msg.messageId,
|
|
1347
|
+
replyToMessageId: msg.replyToMessageId,
|
|
1348
|
+
threadId: msg.threadId,
|
|
746
1349
|
}
|
|
747
1350
|
const inboundText = formatInboundUserText(msg)
|
|
748
1351
|
const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
|
|
749
1352
|
const firstImagePath = msg.media?.find((m) => m.type === 'image')?.localPath
|
|
1353
|
+
const threadContextBlock = buildConnectorThreadContextBlock(msg)
|
|
750
1354
|
|
|
751
1355
|
// Parse mentions from the message text
|
|
752
1356
|
let mentions = parseMentions(msg.text || '', agents, chatroom.agentIds)
|
|
@@ -821,10 +1425,10 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
821
1425
|
continue
|
|
822
1426
|
}
|
|
823
1427
|
|
|
824
|
-
const syntheticSession =
|
|
1428
|
+
const syntheticSession = ensureSyntheticSession(agent, chatroomId)
|
|
825
1429
|
const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
|
|
826
1430
|
const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
|
|
827
|
-
const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
|
|
1431
|
+
const fullSystemPrompt = [agentSystemPrompt, chatroomContext, threadContextBlock].filter(Boolean).join('\n\n')
|
|
828
1432
|
const history = buildHistoryForAgent(freshChatroom, agent.id)
|
|
829
1433
|
|
|
830
1434
|
try {
|
|
@@ -882,7 +1486,10 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
882
1486
|
}
|
|
883
1487
|
}
|
|
884
1488
|
|
|
885
|
-
if (responses.length === 0)
|
|
1489
|
+
if (responses.length === 0) {
|
|
1490
|
+
await maybeSendStatusReaction(connector, msg, 'silent')
|
|
1491
|
+
return NO_MESSAGE_SENTINEL
|
|
1492
|
+
}
|
|
886
1493
|
|
|
887
1494
|
const joined = responses.join('\n\n')
|
|
888
1495
|
// Extract embedded media from agent responses and send them via connector
|
|
@@ -891,9 +1498,18 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
891
1498
|
if (filesToSend.length > 0) {
|
|
892
1499
|
const inst = running.get(connector.id)
|
|
893
1500
|
if (inst?.sendMessage) {
|
|
1501
|
+
const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound: msg })
|
|
894
1502
|
for (const file of filesToSend) {
|
|
895
1503
|
try {
|
|
896
|
-
await
|
|
1504
|
+
await sendConnectorMessage({
|
|
1505
|
+
connectorId: connector.id,
|
|
1506
|
+
channelId: msg.channelId,
|
|
1507
|
+
text: '',
|
|
1508
|
+
mediaPath: file.path,
|
|
1509
|
+
caption: file.alt || undefined,
|
|
1510
|
+
replyToMessageId: replyOptions.replyToMessageId,
|
|
1511
|
+
threadId: replyOptions.threadId,
|
|
1512
|
+
})
|
|
897
1513
|
console.log(`[connector] Sent chatroom media to ${msg.platform}: ${path.basename(file.path)}`)
|
|
898
1514
|
} catch (err: unknown) {
|
|
899
1515
|
console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
|
|
@@ -903,6 +1519,9 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
903
1519
|
return extracted.cleanText || '(no response)'
|
|
904
1520
|
}
|
|
905
1521
|
return joined
|
|
1522
|
+
} finally {
|
|
1523
|
+
stopTyping?.()
|
|
1524
|
+
}
|
|
906
1525
|
}
|
|
907
1526
|
|
|
908
1527
|
/** Route an inbound message through the assigned agent and return the response */
|
|
@@ -927,84 +1546,11 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
927
1546
|
preferredCredentialId: agent.credentialId || null,
|
|
928
1547
|
})
|
|
929
1548
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
'connector-message',
|
|
936
|
-
)
|
|
937
|
-
requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
|
|
938
|
-
|
|
939
|
-
// Log connector trigger
|
|
940
|
-
const triggerSessionKey = `connector:${connector.id}:${msg.channelId}`
|
|
941
|
-
const allSessions = loadSessions()
|
|
942
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
943
|
-
const existingSession = Object.values(allSessions).find((s: any) => s.name === triggerSessionKey)
|
|
944
|
-
if (existingSession) {
|
|
945
|
-
logExecution(existingSession.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
|
|
946
|
-
agentId: agent.id,
|
|
947
|
-
detail: {
|
|
948
|
-
source: 'connector',
|
|
949
|
-
platform: msg.platform,
|
|
950
|
-
connectorId: connector.id,
|
|
951
|
-
channelId: msg.channelId,
|
|
952
|
-
senderName: msg.senderName,
|
|
953
|
-
messagePreview: (msg.text || '').slice(0, 200),
|
|
954
|
-
hasMedia: !!(msg.media?.length || msg.imageUrl),
|
|
955
|
-
},
|
|
956
|
-
})
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// Resolve API key for the agent's provider
|
|
960
|
-
let apiKey: string | null = null
|
|
961
|
-
if (agent.credentialId) {
|
|
962
|
-
const creds = loadCredentials()
|
|
963
|
-
const cred = creds[agent.credentialId]
|
|
964
|
-
if (cred?.encryptedKey) {
|
|
965
|
-
try { apiKey = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// Find a session for this connector message.
|
|
970
|
-
// Prefer the agent's thread session (visible in the agent chat UI) so connector
|
|
971
|
-
// messages appear inline alongside web UI messages.
|
|
972
|
-
// Fall back to a connector-keyed session if the agent has no thread session.
|
|
973
|
-
const sessionKey = `connector:${connector.id}:${msg.channelId}`
|
|
974
|
-
const sessions = loadSessions()
|
|
975
|
-
let session = (agent.threadSessionId && sessions[agent.threadSessionId])
|
|
976
|
-
? sessions[agent.threadSessionId]
|
|
977
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
978
|
-
: Object.values(sessions).find((s: any) => s.name === sessionKey)
|
|
979
|
-
if (!session) {
|
|
980
|
-
const id = genId()
|
|
981
|
-
session = {
|
|
982
|
-
id,
|
|
983
|
-
name: sessionKey,
|
|
984
|
-
cwd: WORKSPACE_DIR,
|
|
985
|
-
user: 'connector',
|
|
986
|
-
provider: agent.provider === 'claude-cli' ? 'anthropic' : agent.provider,
|
|
987
|
-
model: agent.model,
|
|
988
|
-
credentialId: agent.credentialId || null,
|
|
989
|
-
apiEndpoint: agent.apiEndpoint || null,
|
|
990
|
-
claudeSessionId: null,
|
|
991
|
-
codexThreadId: null,
|
|
992
|
-
opencodeSessionId: null,
|
|
993
|
-
delegateResumeIds: {
|
|
994
|
-
claudeCode: null,
|
|
995
|
-
codex: null,
|
|
996
|
-
opencode: null,
|
|
997
|
-
},
|
|
998
|
-
messages: [],
|
|
999
|
-
createdAt: Date.now(),
|
|
1000
|
-
lastActiveAt: Date.now(),
|
|
1001
|
-
sessionType: 'human' as const,
|
|
1002
|
-
agentId: agent.id,
|
|
1003
|
-
plugins: agent.plugins || agent.tools || [],
|
|
1004
|
-
}
|
|
1005
|
-
sessions[id] = session
|
|
1006
|
-
saveSessions(sessions)
|
|
1007
|
-
}
|
|
1549
|
+
const { session, sessionKey, wasCreated, staleReason, clearedMessages } = resolveDirectSession({
|
|
1550
|
+
connector,
|
|
1551
|
+
msg,
|
|
1552
|
+
agent,
|
|
1553
|
+
})
|
|
1008
1554
|
|
|
1009
1555
|
const parsedCommand = parseConnectorCommand(msg.text || '')
|
|
1010
1556
|
if (parsedCommand?.name === 'pair') {
|
|
@@ -1039,6 +1585,26 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1039
1585
|
return accessPolicyResult
|
|
1040
1586
|
}
|
|
1041
1587
|
|
|
1588
|
+
const groupGate = evaluateGroupPolicy({
|
|
1589
|
+
connector,
|
|
1590
|
+
msg,
|
|
1591
|
+
session,
|
|
1592
|
+
aliases: [agent.name, connector.name],
|
|
1593
|
+
})
|
|
1594
|
+
if (!groupGate.allowed) {
|
|
1595
|
+
logExecution(session.id, 'decision', 'Connector inbound blocked by group policy', {
|
|
1596
|
+
agentId: agent.id,
|
|
1597
|
+
detail: {
|
|
1598
|
+
platform: msg.platform,
|
|
1599
|
+
channelId: msg.channelId,
|
|
1600
|
+
senderId: msg.senderId,
|
|
1601
|
+
groupPolicy: resolveConnectorSessionPolicy(connector, msg, session).groupPolicy,
|
|
1602
|
+
reason: groupGate.reason,
|
|
1603
|
+
},
|
|
1604
|
+
})
|
|
1605
|
+
return NO_MESSAGE_SENTINEL
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1042
1608
|
if (parsedCommand) {
|
|
1043
1609
|
const commandResult = await handleConnectorCommand({
|
|
1044
1610
|
command: parsedCommand,
|
|
@@ -1059,6 +1625,58 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1059
1625
|
return commandResult
|
|
1060
1626
|
}
|
|
1061
1627
|
|
|
1628
|
+
await maybeSendStatusReaction(connector, msg, 'processing')
|
|
1629
|
+
const stopTyping = startConnectorTypingLoop(connector, msg)
|
|
1630
|
+
try {
|
|
1631
|
+
// Enqueue system event + heartbeat wake for the agent only after access/gating checks pass.
|
|
1632
|
+
const preview = (msg.text || '').slice(0, 80)
|
|
1633
|
+
enqueueSystemEvent(
|
|
1634
|
+
sessionKey,
|
|
1635
|
+
`Inbound message from ${msg.platform}: ${preview}`,
|
|
1636
|
+
'connector-message',
|
|
1637
|
+
)
|
|
1638
|
+
requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
|
|
1639
|
+
|
|
1640
|
+
logExecution(session.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
|
|
1641
|
+
agentId: agent.id,
|
|
1642
|
+
detail: {
|
|
1643
|
+
source: 'connector',
|
|
1644
|
+
platform: msg.platform,
|
|
1645
|
+
connectorId: connector.id,
|
|
1646
|
+
channelId: msg.channelId,
|
|
1647
|
+
senderName: msg.senderName,
|
|
1648
|
+
sessionKey,
|
|
1649
|
+
messagePreview: (msg.text || '').slice(0, 200),
|
|
1650
|
+
hasMedia: !!(msg.media?.length || msg.imageUrl),
|
|
1651
|
+
staleReason: staleReason || null,
|
|
1652
|
+
clearedMessages: clearedMessages || 0,
|
|
1653
|
+
},
|
|
1654
|
+
})
|
|
1655
|
+
|
|
1656
|
+
// Resolve API key for the effective session provider, preferring matching fallback credentials.
|
|
1657
|
+
let apiKey: string | null = null
|
|
1658
|
+
const sessionCredentialIds = [
|
|
1659
|
+
session.credentialId,
|
|
1660
|
+
...(Array.isArray(session.fallbackCredentialIds) ? session.fallbackCredentialIds : []),
|
|
1661
|
+
].filter(Boolean) as string[]
|
|
1662
|
+
if (sessionCredentialIds.length > 0) {
|
|
1663
|
+
const creds = loadCredentials()
|
|
1664
|
+
const matching = sessionCredentialIds.find((credentialId) => creds[credentialId]?.provider === session.provider)
|
|
1665
|
+
const ordered = matching
|
|
1666
|
+
? [matching, ...sessionCredentialIds.filter((credentialId) => credentialId !== matching)]
|
|
1667
|
+
: sessionCredentialIds
|
|
1668
|
+
for (const credentialId of ordered) {
|
|
1669
|
+
const cred = creds[credentialId]
|
|
1670
|
+
if (!cred?.encryptedKey) continue
|
|
1671
|
+
try {
|
|
1672
|
+
apiKey = decryptKey(cred.encryptedKey)
|
|
1673
|
+
break
|
|
1674
|
+
} catch {
|
|
1675
|
+
// Try the next candidate.
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1062
1680
|
// Build system prompt: [identity] \n\n [userPrompt] \n\n [soul] \n\n [systemPrompt]
|
|
1063
1681
|
const settings = loadSettings()
|
|
1064
1682
|
const promptParts: string[] = []
|
|
@@ -1067,6 +1685,8 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1067
1685
|
if (agent.description) identityLines.push(agent.description)
|
|
1068
1686
|
identityLines.push('I should always refer to myself by this name. I am not "Assistant" — I have my own name and identity.')
|
|
1069
1687
|
promptParts.push(identityLines.join(' '))
|
|
1688
|
+
const continuityBlock = buildIdentityContinuityContext(session as Session, agent)
|
|
1689
|
+
if (continuityBlock) promptParts.push(continuityBlock)
|
|
1070
1690
|
if (settings.userPrompt) promptParts.push(settings.userPrompt)
|
|
1071
1691
|
promptParts.push(buildCurrentDateTimePromptContext())
|
|
1072
1692
|
if (agent.soul) promptParts.push(agent.soul)
|
|
@@ -1078,12 +1698,12 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1078
1698
|
if (skill?.content) promptParts.push(`## Skill: ${skill.name}\n${skill.content}`)
|
|
1079
1699
|
}
|
|
1080
1700
|
}
|
|
1081
|
-
const thinkLevel =
|
|
1082
|
-
? session.connectorThinkLevel.trim().toLowerCase()
|
|
1083
|
-
: ''
|
|
1701
|
+
const thinkLevel = resolveConnectorSessionPolicy(connector, msg, session).thinkingLevel || ''
|
|
1084
1702
|
if (thinkLevel) {
|
|
1085
1703
|
promptParts.push(`Connector thinking guidance: ${thinkLevel}. Keep responses concise and useful for chat.`)
|
|
1086
1704
|
}
|
|
1705
|
+
const threadContextBlock = buildConnectorThreadContextBlock(msg, { isFirstThreadTurn: wasCreated })
|
|
1706
|
+
if (threadContextBlock) promptParts.push(threadContextBlock)
|
|
1087
1707
|
// Add connector context
|
|
1088
1708
|
promptParts.push(`\nYou are receiving messages via ${msg.platform}. The user "${msg.senderName}" is messaging from channel "${msg.channelName || msg.channelId}". Respond naturally and conversationally.
|
|
1089
1709
|
|
|
@@ -1128,6 +1748,9 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1128
1748
|
channelId: msg.channelId,
|
|
1129
1749
|
senderId: msg.senderId,
|
|
1130
1750
|
senderName: msg.senderName,
|
|
1751
|
+
messageId: msg.messageId,
|
|
1752
|
+
replyToMessageId: msg.replyToMessageId,
|
|
1753
|
+
threadId: msg.threadId,
|
|
1131
1754
|
}
|
|
1132
1755
|
session.messages.push({
|
|
1133
1756
|
role: 'user',
|
|
@@ -1139,23 +1762,23 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1139
1762
|
source: messageSource,
|
|
1140
1763
|
})
|
|
1141
1764
|
session.lastActiveAt = Date.now()
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
saveSessions(s1)
|
|
1765
|
+
updateSessionConnectorContext(session, connector, msg, sessionKey)
|
|
1766
|
+
persistSessionRecord(session)
|
|
1145
1767
|
notify(`messages:${session.id}`)
|
|
1146
1768
|
|
|
1147
1769
|
// Stream the response
|
|
1148
1770
|
let fullText = ''
|
|
1149
1771
|
let mediaExtractionText = ''
|
|
1150
1772
|
let connectorToolDeliveredCurrentChannel = false
|
|
1773
|
+
let connectorToolDeliveredMessageId: string | undefined
|
|
1151
1774
|
const hasTools = session.plugins?.length && session.provider !== 'claude-cli'
|
|
1152
|
-
console.log(`[connector] Routing message to agent "${agent.name}" (${
|
|
1775
|
+
console.log(`[connector] Routing message to agent "${agent.name}" (${session.provider}/${session.model}), hasTools=${!!hasTools}`)
|
|
1153
1776
|
|
|
1154
1777
|
if (hasTools) {
|
|
1155
1778
|
try {
|
|
1156
1779
|
const toolMediaOutputs: string[] = []
|
|
1157
1780
|
const result = await streamAgentChat({
|
|
1158
|
-
session,
|
|
1781
|
+
session: session as Session,
|
|
1159
1782
|
message: modelInputText,
|
|
1160
1783
|
imagePath: firstImagePath,
|
|
1161
1784
|
attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
|
|
@@ -1180,6 +1803,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1180
1803
|
: parsed.to
|
|
1181
1804
|
if (inboundTarget && outboundTarget && inboundTarget === outboundTarget) {
|
|
1182
1805
|
connectorToolDeliveredCurrentChannel = true
|
|
1806
|
+
if (parsed.messageId) connectorToolDeliveredMessageId = parsed.messageId
|
|
1183
1807
|
}
|
|
1184
1808
|
}
|
|
1185
1809
|
}
|
|
@@ -1202,7 +1826,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1202
1826
|
if (!provider) return '[Error] Provider not found.'
|
|
1203
1827
|
|
|
1204
1828
|
await provider.handler.streamChat({
|
|
1205
|
-
session,
|
|
1829
|
+
session: session as Session,
|
|
1206
1830
|
message: modelInputText,
|
|
1207
1831
|
imagePath: firstImagePath,
|
|
1208
1832
|
apiKey,
|
|
@@ -1225,6 +1849,17 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1225
1849
|
// If the agent chose NO_MESSAGE, skip saving it to history — the user's message
|
|
1226
1850
|
// is already recorded, and saving the sentinel would pollute the LLM's context
|
|
1227
1851
|
if (isNoMessage(fullText)) {
|
|
1852
|
+
if (connectorToolDeliveredCurrentChannel) {
|
|
1853
|
+
session.connectorContext = {
|
|
1854
|
+
...(session.connectorContext || {}),
|
|
1855
|
+
lastOutboundAt: Date.now(),
|
|
1856
|
+
lastOutboundMessageId: connectorToolDeliveredMessageId || session.connectorContext?.lastOutboundMessageId || null,
|
|
1857
|
+
}
|
|
1858
|
+
persistSessionRecord(session)
|
|
1859
|
+
await maybeSendStatusReaction(connector, msg, 'sent')
|
|
1860
|
+
} else {
|
|
1861
|
+
await maybeSendStatusReaction(connector, msg, 'silent')
|
|
1862
|
+
}
|
|
1228
1863
|
console.log(`[connector] Agent returned NO_MESSAGE — suppressing outbound reply`)
|
|
1229
1864
|
logExecution(session.id, 'decision', 'Agent suppressed outbound (NO_MESSAGE)', {
|
|
1230
1865
|
agentId: agent.id,
|
|
@@ -1251,13 +1886,13 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1251
1886
|
connectorId: connector.id,
|
|
1252
1887
|
connectorName: connector.name,
|
|
1253
1888
|
channelId: msg.channelId,
|
|
1889
|
+
replyToMessageId: msg.messageId,
|
|
1890
|
+
threadId: msg.threadId,
|
|
1254
1891
|
}
|
|
1255
1892
|
if (fullText.trim()) {
|
|
1256
1893
|
session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
|
|
1257
1894
|
session.lastActiveAt = Date.now()
|
|
1258
|
-
|
|
1259
|
-
s2[session.id] = session
|
|
1260
|
-
saveSessions(s2)
|
|
1895
|
+
persistSessionRecord(session)
|
|
1261
1896
|
notify(`messages:${session.id}`)
|
|
1262
1897
|
}
|
|
1263
1898
|
|
|
@@ -1275,9 +1910,19 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1275
1910
|
if (filesToSend.length > 0) {
|
|
1276
1911
|
const inst = running.get(connector.id)
|
|
1277
1912
|
if (inst?.sendMessage) {
|
|
1913
|
+
const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound: msg })
|
|
1278
1914
|
for (const file of filesToSend) {
|
|
1279
1915
|
try {
|
|
1280
|
-
await
|
|
1916
|
+
await sendConnectorMessage({
|
|
1917
|
+
connectorId: connector.id,
|
|
1918
|
+
channelId: msg.channelId,
|
|
1919
|
+
text: '',
|
|
1920
|
+
sessionId: session.id,
|
|
1921
|
+
mediaPath: file.path,
|
|
1922
|
+
caption: file.alt || undefined,
|
|
1923
|
+
replyToMessageId: replyOptions.replyToMessageId,
|
|
1924
|
+
threadId: replyOptions.threadId,
|
|
1925
|
+
})
|
|
1281
1926
|
console.log(`[connector] Sent media to ${msg.platform}: ${path.basename(file.path)}`)
|
|
1282
1927
|
logExecution(session.id, 'outbound', 'Connector media sent', {
|
|
1283
1928
|
agentId: agent.id,
|
|
@@ -1317,8 +1962,11 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1317
1962
|
return extractedFromReply.cleanText || '(no response)'
|
|
1318
1963
|
}
|
|
1319
1964
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1965
|
+
if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
|
|
1966
|
+
return fullText || '(no response)'
|
|
1967
|
+
} finally {
|
|
1968
|
+
stopTyping?.()
|
|
1969
|
+
}
|
|
1322
1970
|
}
|
|
1323
1971
|
|
|
1324
1972
|
routeMessageHandlerRef.current = routeMessage
|
|
@@ -1427,11 +2075,22 @@ export async function stopConnector(connectorId: string): Promise<void> {
|
|
|
1427
2075
|
running.delete(connectorId)
|
|
1428
2076
|
}
|
|
1429
2077
|
|
|
2078
|
+
for (const [debounceKey, entry] of pendingInboundDebounce.entries()) {
|
|
2079
|
+
if (entry.connector.id !== connectorId) continue
|
|
2080
|
+
clearTimeout(entry.timer)
|
|
2081
|
+
pendingInboundDebounce.delete(debounceKey)
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1430
2084
|
for (const [followupId, followup] of scheduledFollowups.entries()) {
|
|
1431
2085
|
if (followup.connectorId !== connectorId) continue
|
|
1432
2086
|
clearTimeout(followup.timer)
|
|
1433
2087
|
scheduledFollowups.delete(followupId)
|
|
1434
2088
|
}
|
|
2089
|
+
for (const [key, entry] of scheduledFollowupByDedupe.entries()) {
|
|
2090
|
+
if (!scheduledFollowups.has(entry.id)) {
|
|
2091
|
+
scheduledFollowupByDedupe.delete(key)
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
1435
2094
|
|
|
1436
2095
|
const connectors = loadConnectors()
|
|
1437
2096
|
const connector = connectors[connectorId]
|
|
@@ -1582,6 +2241,141 @@ export function getRunningInstance(connectorId: string): ConnectorInstance | und
|
|
|
1582
2241
|
return running.get(connectorId)
|
|
1583
2242
|
}
|
|
1584
2243
|
|
|
2244
|
+
export function getConnectorReplySendOptions(params: {
|
|
2245
|
+
connectorId: string
|
|
2246
|
+
inbound: InboundMessage
|
|
2247
|
+
}): { replyToMessageId?: string; threadId?: string } {
|
|
2248
|
+
const connectors = loadConnectors()
|
|
2249
|
+
const connector = connectors[params.connectorId] as Connector | undefined
|
|
2250
|
+
if (!connector) return {}
|
|
2251
|
+
const session = findDirectSessionForInbound(connector, params.inbound)
|
|
2252
|
+
const policy = resolveConnectorSessionPolicy(connector, params.inbound, session)
|
|
2253
|
+
return shouldReplyToInboundMessage({
|
|
2254
|
+
msg: params.inbound,
|
|
2255
|
+
session,
|
|
2256
|
+
policy,
|
|
2257
|
+
})
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
export async function recordConnectorOutboundDelivery(params: {
|
|
2261
|
+
connectorId: string
|
|
2262
|
+
inbound: InboundMessage
|
|
2263
|
+
messageId?: string
|
|
2264
|
+
state?: 'sent' | 'silent'
|
|
2265
|
+
}): Promise<void> {
|
|
2266
|
+
const connectors = loadConnectors()
|
|
2267
|
+
const connector = connectors[params.connectorId] as Connector | undefined
|
|
2268
|
+
if (!connector) return
|
|
2269
|
+
const session = findDirectSessionForInbound(connector, params.inbound)
|
|
2270
|
+
if (session) {
|
|
2271
|
+
session.connectorContext = {
|
|
2272
|
+
...(session.connectorContext || {}),
|
|
2273
|
+
lastOutboundAt: Date.now(),
|
|
2274
|
+
lastOutboundMessageId: params.messageId || session.connectorContext?.lastOutboundMessageId || null,
|
|
2275
|
+
threadId: params.inbound.threadId || session.connectorContext?.threadId || null,
|
|
2276
|
+
}
|
|
2277
|
+
const history = Array.isArray(session.messages) ? session.messages : []
|
|
2278
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
2279
|
+
const entry = history[i]
|
|
2280
|
+
if (entry?.role !== 'assistant') continue
|
|
2281
|
+
const source: Partial<MessageSource> = entry?.source || {}
|
|
2282
|
+
if (source.connectorId !== connector.id) continue
|
|
2283
|
+
if (source.channelId !== params.inbound.channelId) continue
|
|
2284
|
+
if (!source.messageId && params.messageId) {
|
|
2285
|
+
entry.source = {
|
|
2286
|
+
platform: source.platform || connector.platform,
|
|
2287
|
+
connectorId: source.connectorId || connector.id,
|
|
2288
|
+
connectorName: source.connectorName || connector.name,
|
|
2289
|
+
channelId: source.channelId || params.inbound.channelId,
|
|
2290
|
+
senderId: source.senderId,
|
|
2291
|
+
senderName: source.senderName,
|
|
2292
|
+
messageId: params.messageId,
|
|
2293
|
+
replyToMessageId: source.replyToMessageId || params.inbound.messageId,
|
|
2294
|
+
threadId: source.threadId || params.inbound.threadId,
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
break
|
|
2298
|
+
}
|
|
2299
|
+
persistSessionRecord(session)
|
|
2300
|
+
notify(`messages:${session.id}`)
|
|
2301
|
+
}
|
|
2302
|
+
if (params.state) {
|
|
2303
|
+
await maybeSendStatusReaction(connector, params.inbound, params.state)
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
export async function performConnectorMessageAction(params: {
|
|
2308
|
+
connectorId?: string
|
|
2309
|
+
platform?: string
|
|
2310
|
+
channelId: string
|
|
2311
|
+
action: 'react' | 'edit' | 'delete' | 'pin'
|
|
2312
|
+
messageId?: string
|
|
2313
|
+
emoji?: string
|
|
2314
|
+
text?: string
|
|
2315
|
+
sessionId?: string | null
|
|
2316
|
+
targetMessage?: 'last_inbound' | 'last_outbound'
|
|
2317
|
+
}): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
|
|
2318
|
+
const connectors = loadConnectors()
|
|
2319
|
+
const requestedId = params.connectorId?.trim()
|
|
2320
|
+
let connector: Connector | undefined
|
|
2321
|
+
let connectorId: string | undefined
|
|
2322
|
+
|
|
2323
|
+
if (requestedId) {
|
|
2324
|
+
connector = connectors[requestedId] as Connector | undefined
|
|
2325
|
+
connectorId = requestedId
|
|
2326
|
+
if (!connector) throw new Error(`Connector not found: ${requestedId}`)
|
|
2327
|
+
} else {
|
|
2328
|
+
const candidates = Object.values(connectors) as Connector[]
|
|
2329
|
+
const filtered = candidates.filter((item) => (!params.platform || item.platform === params.platform) && running.has(item.id))
|
|
2330
|
+
if (!filtered.length) throw new Error(`No running connector found${params.platform ? ` for platform "${params.platform}"` : ''}.`)
|
|
2331
|
+
connector = filtered[0]
|
|
2332
|
+
connectorId = connector.id
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
if (!connector || !connectorId) throw new Error('Connector resolution failed.')
|
|
2336
|
+
const instance = running.get(connectorId)
|
|
2337
|
+
if (!instance) throw new Error(`Connector "${connectorId}" is not running.`)
|
|
2338
|
+
|
|
2339
|
+
const targetMessageId = (() => {
|
|
2340
|
+
if (params.messageId?.trim()) return params.messageId.trim()
|
|
2341
|
+
if (!params.sessionId) return ''
|
|
2342
|
+
const session = loadSessions()[params.sessionId]
|
|
2343
|
+
if (!session) return ''
|
|
2344
|
+
if (params.targetMessage === 'last_inbound') return session.connectorContext?.lastInboundMessageId || ''
|
|
2345
|
+
if (params.targetMessage === 'last_outbound' || !params.targetMessage) return session.connectorContext?.lastOutboundMessageId || ''
|
|
2346
|
+
return ''
|
|
2347
|
+
})()
|
|
2348
|
+
if (!targetMessageId) throw new Error('messageId is required for connector message actions.')
|
|
2349
|
+
|
|
2350
|
+
switch (params.action) {
|
|
2351
|
+
case 'react':
|
|
2352
|
+
if (!instance.sendReaction) throw new Error(`Connector "${connector.name}" does not support reactions.`)
|
|
2353
|
+
if (!params.emoji?.trim()) throw new Error('emoji is required for react action.')
|
|
2354
|
+
await instance.sendReaction(params.channelId, targetMessageId, params.emoji.trim())
|
|
2355
|
+
break
|
|
2356
|
+
case 'edit':
|
|
2357
|
+
if (!instance.editMessage) throw new Error(`Connector "${connector.name}" does not support edits.`)
|
|
2358
|
+
if (!params.text?.trim()) throw new Error('text is required for edit action.')
|
|
2359
|
+
await instance.editMessage(params.channelId, targetMessageId, params.text.trim())
|
|
2360
|
+
break
|
|
2361
|
+
case 'delete':
|
|
2362
|
+
if (!instance.deleteMessage) throw new Error(`Connector "${connector.name}" does not support deletes.`)
|
|
2363
|
+
await instance.deleteMessage(params.channelId, targetMessageId)
|
|
2364
|
+
break
|
|
2365
|
+
case 'pin':
|
|
2366
|
+
if (!instance.pinMessage) throw new Error(`Connector "${connector.name}" does not support pinning.`)
|
|
2367
|
+
await instance.pinMessage(params.channelId, targetMessageId)
|
|
2368
|
+
break
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
return {
|
|
2372
|
+
connectorId,
|
|
2373
|
+
platform: connector.platform,
|
|
2374
|
+
channelId: params.channelId,
|
|
2375
|
+
messageId: targetMessageId,
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
|
|
1585
2379
|
/**
|
|
1586
2380
|
* Send an outbound message through a running connector.
|
|
1587
2381
|
* Intended for proactive agent notifications (e.g. WhatsApp updates).
|
|
@@ -1591,12 +2385,15 @@ export async function sendConnectorMessage(params: {
|
|
|
1591
2385
|
platform?: string
|
|
1592
2386
|
channelId: string
|
|
1593
2387
|
text: string
|
|
2388
|
+
sessionId?: string | null
|
|
1594
2389
|
imageUrl?: string
|
|
1595
2390
|
fileUrl?: string
|
|
1596
2391
|
mediaPath?: string
|
|
1597
2392
|
mimeType?: string
|
|
1598
2393
|
fileName?: string
|
|
1599
2394
|
caption?: string
|
|
2395
|
+
replyToMessageId?: string
|
|
2396
|
+
threadId?: string
|
|
1600
2397
|
ptt?: boolean
|
|
1601
2398
|
}): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
|
|
1602
2399
|
const connectors = loadConnectors()
|
|
@@ -1650,6 +2447,8 @@ export async function sendConnectorMessage(params: {
|
|
|
1650
2447
|
mimeType: params.mimeType,
|
|
1651
2448
|
fileName: params.fileName,
|
|
1652
2449
|
caption: params.caption,
|
|
2450
|
+
replyToMessageId: params.replyToMessageId,
|
|
2451
|
+
threadId: params.threadId,
|
|
1653
2452
|
ptt: params.ptt,
|
|
1654
2453
|
}
|
|
1655
2454
|
|
|
@@ -1668,6 +2467,41 @@ export async function sendConnectorMessage(params: {
|
|
|
1668
2467
|
}
|
|
1669
2468
|
|
|
1670
2469
|
const result = await instance.sendMessage(channelId, outboundText, outboundOptions)
|
|
2470
|
+
if (params.sessionId) {
|
|
2471
|
+
const sessions = loadSessions()
|
|
2472
|
+
const session = sessions[params.sessionId]
|
|
2473
|
+
if (session) {
|
|
2474
|
+
session.connectorContext = {
|
|
2475
|
+
...(session.connectorContext || {}),
|
|
2476
|
+
connectorId,
|
|
2477
|
+
platform: connector.platform,
|
|
2478
|
+
channelId,
|
|
2479
|
+
threadId: params.threadId || session.connectorContext?.threadId || null,
|
|
2480
|
+
lastOutboundAt: Date.now(),
|
|
2481
|
+
lastOutboundMessageId: result?.messageId || session.connectorContext?.lastOutboundMessageId || null,
|
|
2482
|
+
}
|
|
2483
|
+
const history = Array.isArray(session.messages) ? session.messages : []
|
|
2484
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
2485
|
+
const entry = history[i]
|
|
2486
|
+
if (entry?.role !== 'assistant') continue
|
|
2487
|
+
const source: Partial<MessageSource> = entry?.source || {}
|
|
2488
|
+
if (source.connectorId !== connectorId) continue
|
|
2489
|
+
if (source.channelId !== channelId) continue
|
|
2490
|
+
if (!source.messageId && result?.messageId) {
|
|
2491
|
+
entry.source = {
|
|
2492
|
+
...source,
|
|
2493
|
+
messageId: result.messageId,
|
|
2494
|
+
threadId: source.threadId || params.threadId,
|
|
2495
|
+
replyToMessageId: source.replyToMessageId || params.replyToMessageId,
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
break
|
|
2499
|
+
}
|
|
2500
|
+
sessions[session.id] = session
|
|
2501
|
+
saveSessions(sessions)
|
|
2502
|
+
notify(`messages:${session.id}`)
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
1671
2505
|
return {
|
|
1672
2506
|
connectorId,
|
|
1673
2507
|
platform: connector.platform,
|
|
@@ -1682,16 +2516,39 @@ export function scheduleConnectorFollowUp(params: {
|
|
|
1682
2516
|
channelId: string
|
|
1683
2517
|
text: string
|
|
1684
2518
|
delaySec?: number
|
|
2519
|
+
dedupeKey?: string
|
|
2520
|
+
replaceExisting?: boolean
|
|
2521
|
+
sessionId?: string | null
|
|
1685
2522
|
imageUrl?: string
|
|
1686
2523
|
fileUrl?: string
|
|
1687
2524
|
mediaPath?: string
|
|
1688
2525
|
mimeType?: string
|
|
1689
2526
|
fileName?: string
|
|
1690
2527
|
caption?: string
|
|
2528
|
+
replyToMessageId?: string
|
|
2529
|
+
threadId?: string
|
|
1691
2530
|
ptt?: boolean
|
|
1692
2531
|
}): { followUpId: string; sendAt: number } {
|
|
1693
2532
|
const delaySecRaw = Number.isFinite(params.delaySec) ? Number(params.delaySec) : 300
|
|
1694
2533
|
const delayMs = Math.max(1_000, Math.min(86_400_000, Math.round(delaySecRaw * 1000)))
|
|
2534
|
+
const dedupeKey = params.dedupeKey || [
|
|
2535
|
+
params.connectorId || params.platform || '',
|
|
2536
|
+
params.channelId,
|
|
2537
|
+
params.threadId || '',
|
|
2538
|
+
(params.text || '').trim().slice(0, 160),
|
|
2539
|
+
].join('|')
|
|
2540
|
+
const existing = scheduledFollowupByDedupe.get(dedupeKey)
|
|
2541
|
+
if (existing && existing.sendAt > Date.now() && !params.replaceExisting) {
|
|
2542
|
+
return { followUpId: existing.id, sendAt: existing.sendAt }
|
|
2543
|
+
}
|
|
2544
|
+
if (existing && params.replaceExisting) {
|
|
2545
|
+
const scheduled = scheduledFollowups.get(existing.id)
|
|
2546
|
+
if (scheduled) {
|
|
2547
|
+
clearTimeout(scheduled.timer)
|
|
2548
|
+
scheduledFollowups.delete(existing.id)
|
|
2549
|
+
}
|
|
2550
|
+
scheduledFollowupByDedupe.delete(dedupeKey)
|
|
2551
|
+
}
|
|
1695
2552
|
const followUpId = genId()
|
|
1696
2553
|
const sendAt = Date.now() + delayMs
|
|
1697
2554
|
|
|
@@ -1701,18 +2558,24 @@ export function scheduleConnectorFollowUp(params: {
|
|
|
1701
2558
|
platform: params.platform,
|
|
1702
2559
|
channelId: params.channelId,
|
|
1703
2560
|
text: params.text,
|
|
2561
|
+
sessionId: params.sessionId,
|
|
1704
2562
|
imageUrl: params.imageUrl,
|
|
1705
2563
|
fileUrl: params.fileUrl,
|
|
1706
2564
|
mediaPath: params.mediaPath,
|
|
1707
2565
|
mimeType: params.mimeType,
|
|
1708
2566
|
fileName: params.fileName,
|
|
1709
2567
|
caption: params.caption,
|
|
2568
|
+
replyToMessageId: params.replyToMessageId,
|
|
2569
|
+
threadId: params.threadId,
|
|
1710
2570
|
ptt: params.ptt,
|
|
1711
2571
|
}).catch((err: unknown) => {
|
|
1712
2572
|
const msg = err instanceof Error ? err.message : String(err)
|
|
1713
2573
|
console.warn(`[connector] Scheduled follow-up ${followUpId} failed: ${msg}`)
|
|
1714
2574
|
}).finally(() => {
|
|
1715
2575
|
scheduledFollowups.delete(followUpId)
|
|
2576
|
+
if (scheduledFollowupByDedupe.get(dedupeKey)?.id === followUpId) {
|
|
2577
|
+
scheduledFollowupByDedupe.delete(dedupeKey)
|
|
2578
|
+
}
|
|
1716
2579
|
})
|
|
1717
2580
|
}, delayMs)
|
|
1718
2581
|
|
|
@@ -1724,6 +2587,7 @@ export function scheduleConnectorFollowUp(params: {
|
|
|
1724
2587
|
sendAt,
|
|
1725
2588
|
timer,
|
|
1726
2589
|
})
|
|
2590
|
+
scheduledFollowupByDedupe.set(dedupeKey, { id: followUpId, sendAt })
|
|
1727
2591
|
|
|
1728
2592
|
return { followUpId, sendAt }
|
|
1729
2593
|
}
|