@swarmclawai/swarmclaw 0.9.3 → 0.9.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.
Files changed (50) hide show
  1. package/README.md +12 -10
  2. package/bundled-skills/google-workspace/SKILL.md +2 -0
  3. package/package.json +1 -1
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  5. package/src/app/api/clawhub/install/route.ts +2 -0
  6. package/src/app/api/skills/[id]/route.ts +4 -0
  7. package/src/app/api/skills/route.ts +4 -0
  8. package/src/components/agents/agent-sheet.tsx +5 -5
  9. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  10. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  11. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  12. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  13. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  14. package/src/lib/server/agents/orchestrator.ts +11 -7
  15. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  16. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  17. package/src/lib/server/chat-execution/chat-execution.ts +74 -26
  18. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +65 -30
  19. package/src/lib/server/chat-execution/stream-agent-chat.ts +69 -25
  20. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  21. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  22. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  23. package/src/lib/server/connectors/manager.test.ts +504 -73
  24. package/src/lib/server/connectors/manager.ts +40 -9
  25. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  26. package/src/lib/server/connectors/session-kind.ts +7 -0
  27. package/src/lib/server/connectors/session.test.ts +104 -0
  28. package/src/lib/server/connectors/session.ts +5 -2
  29. package/src/lib/server/identity-continuity.test.ts +4 -3
  30. package/src/lib/server/identity-continuity.ts +8 -4
  31. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  32. package/src/lib/server/session-reset-policy.test.ts +17 -3
  33. package/src/lib/server/session-reset-policy.ts +4 -2
  34. package/src/lib/server/session-tools/connector.ts +11 -10
  35. package/src/lib/server/session-tools/crud.ts +41 -7
  36. package/src/lib/server/session-tools/index.ts +2 -0
  37. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  38. package/src/lib/server/session-tools/memory.ts +12 -23
  39. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  40. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  41. package/src/lib/server/session-tools/skills.ts +575 -0
  42. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  43. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  44. package/src/lib/server/skills/skill-discovery.ts +4 -0
  45. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  46. package/src/lib/server/skills/skills-normalize.ts +93 -1
  47. package/src/lib/server/storage.ts +1 -1
  48. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  49. package/src/lib/server/tasks/task-followups.ts +88 -13
  50. package/src/types/index.ts +26 -2
@@ -30,6 +30,7 @@ import { markProviderFailure, markProviderSuccess } from '../provider-health'
30
30
  import { syncSessionArchiveMemory } from '@/lib/server/memory/session-archive-memory'
31
31
  import { buildIdentityContinuityContext } from '../identity-continuity'
32
32
  import { ensureAgentThreadSession } from '@/lib/server/agents/agent-thread-session'
33
+ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
33
34
  import { getProvider } from '@/lib/providers'
34
35
  import type { Agent, Connector, MessageSource, Chatroom, ChatroomMessage, Session } from '@/types'
35
36
  import type { ConnectorInstance, InboundMessage } from './types'
@@ -57,6 +58,8 @@ import {
57
58
  textMentionsAlias,
58
59
  } from './policy'
59
60
  import { buildConnectorThreadContextBlock } from './thread-context'
61
+ import { isDirectConnectorSession } from './session-kind'
62
+ import { enforceSenderQuietBoundary } from './contact-boundaries'
60
63
  import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
61
64
  import {
62
65
  buildInboundApprovalSubject as buildInboundApprovalSubjectHelper,
@@ -1198,7 +1201,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
1198
1201
  }
1199
1202
 
1200
1203
  const syntheticSession = ensureSyntheticSession(agent, chatroomId)
1201
- const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
1204
+ const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent, syntheticSession.cwd)
1202
1205
  const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
1203
1206
  const fullSystemPrompt = [agentSystemPrompt, chatroomContext, threadContextBlock].filter(Boolean).join('\n\n')
1204
1207
  const history = buildHistoryForAgent(freshChatroom, agent.id)
@@ -1418,6 +1421,26 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1418
1421
  return NO_MESSAGE_SENTINEL
1419
1422
  }
1420
1423
 
1424
+ const quietBoundary = enforceSenderQuietBoundary({
1425
+ agent,
1426
+ connector,
1427
+ session,
1428
+ msg,
1429
+ })
1430
+ if (quietBoundary.suppress) {
1431
+ logExecution(session.id, 'decision', 'Connector inbound suppressed by sender quiet boundary', {
1432
+ agentId: agent.id,
1433
+ detail: {
1434
+ platform: msg.platform,
1435
+ channelId: msg.channelId,
1436
+ senderId: msg.senderId,
1437
+ senderName: msg.senderName,
1438
+ memoryTitle: quietBoundary.memoryTitle || null,
1439
+ },
1440
+ })
1441
+ return NO_MESSAGE_SENTINEL
1442
+ }
1443
+
1421
1444
  if (parsedCommand) {
1422
1445
  const commandResult = await handleConnectorCommand({
1423
1446
  command: parsedCommand,
@@ -1520,13 +1543,21 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1520
1543
  promptParts.push(buildCurrentDateTimePromptContext())
1521
1544
  if (agent.soul) promptParts.push(agent.soul)
1522
1545
  if (agent.systemPrompt) promptParts.push(agent.systemPrompt)
1523
- if (agent.skillIds?.length) {
1524
- const allSkills = loadSkills()
1525
- for (const skillId of agent.skillIds) {
1526
- const skill = allSkills[skillId]
1527
- if (skill?.content) promptParts.push(`## Skill: ${skill.name}\n${skill.content}`)
1528
- }
1529
- }
1546
+ try {
1547
+ const enabledPlugins = dedup([
1548
+ ...(Array.isArray(session.plugins) ? session.plugins : []),
1549
+ ...(Array.isArray(agent.plugins) ? agent.plugins : []),
1550
+ ...(Array.isArray(agent.tools) ? agent.tools : []),
1551
+ ])
1552
+ const runtimeSkills = resolveRuntimeSkills({
1553
+ cwd: session.cwd,
1554
+ enabledPlugins,
1555
+ agentSkillIds: agent.skillIds || [],
1556
+ storedSkills: loadSkills(),
1557
+ selectedSkillId: session.skillRuntimeState?.selectedSkillId || null,
1558
+ })
1559
+ promptParts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
1560
+ } catch { /* non-critical */ }
1530
1561
  const thinkLevel = resolveConnectorSessionPolicy(connector, msg, session).thinkingLevel || ''
1531
1562
  if (thinkLevel) {
1532
1563
  promptParts.push(`Connector thinking guidance: ${thinkLevel}. Keep responses concise and useful for chat.`)
@@ -2343,7 +2374,7 @@ export async function sendConnectorMessage(params: {
2343
2374
  if (params.sessionId) {
2344
2375
  const sessions = loadSessions()
2345
2376
  const session = sessions[params.sessionId]
2346
- if (session) {
2377
+ if (session && isDirectConnectorSession(session)) {
2347
2378
  session.connectorContext = {
2348
2379
  ...(session.connectorContext || {}),
2349
2380
  connectorId,
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { loadSessions, loadSettings, saveSettings, upsertStoredItem } from '../storage'
6
6
  import type { Session } from '@/types'
7
+ import { isDirectConnectorSession } from './session-kind'
7
8
 
8
9
  const MIGRATION_FLAG = '_migration_allKnownPeerIds'
9
10
 
@@ -17,6 +18,7 @@ export function backfillAllKnownPeerIds(): { migrated: number; skipped: boolean
17
18
  let migrated = 0
18
19
 
19
20
  for (const session of Object.values(sessions)) {
21
+ if (!isDirectConnectorSession(session)) continue
20
22
  const ctx = session?.connectorContext
21
23
  if (!ctx?.connectorId) continue
22
24
  if (Array.isArray(ctx.allKnownPeerIds) && ctx.allKnownPeerIds.length > 0) continue
@@ -0,0 +1,7 @@
1
+ export function isDirectConnectorSession(session?: unknown): boolean {
2
+ if (!session || typeof session !== 'object') return false
3
+ const record = session as Record<string, unknown>
4
+ const user = typeof record.user === 'string' ? record.user.trim() : ''
5
+ const name = typeof record.name === 'string' ? record.name.trim() : ''
6
+ return user === 'connector' || name.startsWith('connector:')
7
+ }
@@ -193,6 +193,110 @@ describe('connectors/session', () => {
193
193
  assert.ok(found)
194
194
  assert.equal(found!.id, created.session.id)
195
195
  })
196
+
197
+ it('returns null when multiple direct sessions match without a unique thread or sender match', () => {
198
+ const now = Date.now()
199
+ storage.saveSessions({
200
+ 'sess-a': {
201
+ id: 'sess-a',
202
+ name: 'connector:discord:alice',
203
+ user: 'connector',
204
+ provider: 'anthropic',
205
+ model: 'claude-3',
206
+ messages: [],
207
+ createdAt: now,
208
+ lastActiveAt: now,
209
+ agentId: 'agent-1',
210
+ connectorContext: {
211
+ connectorId: 'conn-1',
212
+ channelId: 'shared-channel',
213
+ senderId: 'alice-1',
214
+ },
215
+ },
216
+ 'sess-b': {
217
+ id: 'sess-b',
218
+ name: 'connector:discord:bob',
219
+ user: 'connector',
220
+ provider: 'anthropic',
221
+ model: 'claude-3',
222
+ messages: [],
223
+ createdAt: now,
224
+ lastActiveAt: now,
225
+ agentId: 'agent-1',
226
+ connectorContext: {
227
+ connectorId: 'conn-1',
228
+ channelId: 'shared-channel',
229
+ senderId: 'bob-1',
230
+ },
231
+ },
232
+ })
233
+
234
+ const connector = {
235
+ id: 'conn-1',
236
+ name: 'Test',
237
+ platform: 'discord' as const,
238
+ agentId: 'agent-1',
239
+ config: {},
240
+ enabled: true,
241
+ createdAt: now,
242
+ updatedAt: now,
243
+ } as unknown as Connector
244
+
245
+ const result = mod.findDirectSessionForInbound(connector, {
246
+ platform: 'discord',
247
+ channelId: 'shared-channel',
248
+ senderId: 'unknown-user',
249
+ senderName: 'Unknown',
250
+ text: 'hello',
251
+ })
252
+
253
+ assert.equal(result, null)
254
+ })
255
+
256
+ it('returns null when the inbound thread id does not match an existing direct thread session', () => {
257
+ const now = Date.now()
258
+ storage.saveSessions({
259
+ 'sess-thread': {
260
+ id: 'sess-thread',
261
+ name: 'connector:discord:thread',
262
+ user: 'connector',
263
+ provider: 'anthropic',
264
+ model: 'claude-3',
265
+ messages: [],
266
+ createdAt: now,
267
+ lastActiveAt: now,
268
+ agentId: 'agent-1',
269
+ connectorContext: {
270
+ connectorId: 'conn-1',
271
+ channelId: 'channel-1',
272
+ senderId: 'user-1',
273
+ threadId: 'thread-1',
274
+ },
275
+ },
276
+ })
277
+
278
+ const connector = {
279
+ id: 'conn-1',
280
+ name: 'Test',
281
+ platform: 'discord' as const,
282
+ agentId: 'agent-1',
283
+ config: {},
284
+ enabled: true,
285
+ createdAt: now,
286
+ updatedAt: now,
287
+ } as unknown as Connector
288
+
289
+ const result = mod.findDirectSessionForInbound(connector, {
290
+ platform: 'discord',
291
+ channelId: 'channel-1',
292
+ senderId: 'user-1',
293
+ senderName: 'User',
294
+ text: 'hello',
295
+ threadId: 'thread-2',
296
+ })
297
+
298
+ assert.equal(result, null)
299
+ })
196
300
  })
197
301
 
198
302
  // ---- updateSessionConnectorContext ----
@@ -12,6 +12,7 @@ import {
12
12
  resetConnectorSessionRuntime,
13
13
  resolveConnectorSessionPolicy,
14
14
  } from './policy'
15
+ import { isDirectConnectorSession } from './session-kind'
15
16
  import { resolveThreadPersonaLabel } from './thread-context'
16
17
  import type { InboundMessage } from './types'
17
18
 
@@ -40,7 +41,8 @@ export function findDirectSessionForInbound(connector: Connector, msg: InboundMe
40
41
  const senderIds = new Set([msg.senderId, msg.senderIdAlt].filter(Boolean))
41
42
  const sessions = Object.values(loadSessions() as Record<string, ConnectorSession>)
42
43
  const candidates = sessions.filter((session) =>
43
- session?.agentId === effectiveAgentId
44
+ isDirectConnectorSession(session)
45
+ && session?.agentId === effectiveAgentId
44
46
  && session?.connectorContext?.connectorId === connector.id
45
47
  && (
46
48
  channelIds.has(session?.connectorContext?.channelId || '')
@@ -51,6 +53,7 @@ export function findDirectSessionForInbound(connector: Connector, msg: InboundMe
51
53
  if (msg.threadId) {
52
54
  const threadExact = candidates.find((session) => session?.connectorContext?.threadId === msg.threadId)
53
55
  if (threadExact) return threadExact
56
+ return null
54
57
  }
55
58
  const senderExact = candidates.find((session) =>
56
59
  senderIds.has(session?.connectorContext?.senderId || '')
@@ -64,7 +67,7 @@ export function findDirectSessionForInbound(connector: Connector, msg: InboundMe
64
67
  )
65
68
  if (peerIdMatch) return peerIdMatch
66
69
 
67
- return candidates[0] || null
70
+ return candidates.length === 1 ? candidates[0] : null
68
71
  }
69
72
 
70
73
  export function persistSessionRecord(session: ConnectorSession): void {
@@ -32,9 +32,9 @@ test('buildIdentityContinuityContext merges agent and session continuity', () =>
32
32
  test('refreshSessionIdentityState derives fallback continuity fields', () => {
33
33
  const session = {
34
34
  id: 's1',
35
- name: 'Checkout Bug',
35
+ name: 'connector:checkout-bug',
36
36
  cwd: process.cwd(),
37
- user: 'Taylor',
37
+ user: 'connector',
38
38
  provider: 'openai',
39
39
  model: 'gpt-4.1',
40
40
  claudeSessionId: null,
@@ -61,7 +61,8 @@ test('refreshSessionIdentityState derives fallback continuity fields', () => {
61
61
  test('buildIdentityContinuityContext prefers thread persona labels from connector context', () => {
62
62
  const block = buildIdentityContinuityContext(
63
63
  {
64
- name: 'Connector Session',
64
+ name: 'connector:session',
65
+ user: 'connector',
65
66
  connectorContext: {
66
67
  threadId: 'thread-9',
67
68
  threadPersonaLabel: 'Checkout Incident',
@@ -1,4 +1,5 @@
1
1
  import type { Agent, IdentityContinuityState, Session } from '@/types'
2
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
2
3
 
3
4
  function normalizeText(value: unknown, maxChars: number): string | null {
4
5
  if (typeof value !== 'string') return null
@@ -49,11 +50,12 @@ function fallbackSelfSummary(agent?: Partial<Agent> | null): string | null {
49
50
  }
50
51
 
51
52
  function fallbackPersonaLabel(session?: Partial<Session> | null, agent?: Partial<Agent> | null): string | null {
52
- const threadPersona = normalizeText(session?.connectorContext?.threadPersonaLabel, 120)
53
+ const connectorContext = isDirectConnectorSession(session) ? session?.connectorContext : null
54
+ const threadPersona = normalizeText(connectorContext?.threadPersonaLabel, 120)
53
55
  if (threadPersona) return threadPersona
54
- const threadTitle = normalizeText(session?.connectorContext?.threadTitle, 120)
56
+ const threadTitle = normalizeText(connectorContext?.threadTitle, 120)
55
57
  if (threadTitle) return threadTitle
56
- const threadId = normalizeText(session?.connectorContext?.threadId, 80)
58
+ const threadId = normalizeText(connectorContext?.threadId, 80)
57
59
  if (threadId) return `${agent?.name || 'Agent'} thread ${threadId}`
58
60
  const sessionName = normalizeText(session?.name, 120)
59
61
  if (sessionName && !/^new chat$/i.test(sessionName)) return sessionName
@@ -61,7 +63,9 @@ function fallbackPersonaLabel(session?: Partial<Session> | null, agent?: Partial
61
63
  }
62
64
 
63
65
  function fallbackRelationshipSummary(session?: Partial<Session> | null): string | null {
64
- const sender = normalizeText(session?.connectorContext?.senderName, 80)
66
+ const sender = isDirectConnectorSession(session)
67
+ ? normalizeText(session?.connectorContext?.senderName, 80)
68
+ : null
65
69
  if (sender) return `Ongoing conversation with ${sender}.`
66
70
  const user = normalizeText(session?.user, 80)
67
71
  if (user && user !== 'user') return `Ongoing conversation with ${user}.`
@@ -5,6 +5,7 @@ import type { Agent, MemoryEntry, MemoryReference, Session } from '@/types'
5
5
  import { getMemoryDb } from '@/lib/server/memory/memory-db'
6
6
  import { loadAgents, loadSessions, saveSessions } from '@/lib/server/storage'
7
7
  import { DATA_DIR } from '@/lib/server/data-dir'
8
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
8
9
 
9
10
  const MAX_ARCHIVE_MESSAGES = 36
10
11
  const MAX_ARCHIVE_LINE_CHARS = 320
@@ -16,7 +17,7 @@ function toOneLine(value: unknown, maxChars: number): string {
16
17
 
17
18
  function messageSpeaker(session: Session, agent: Partial<Agent> | null | undefined, message: Session['messages'][number]): string {
18
19
  if (message.role === 'assistant') return agent?.name || 'assistant'
19
- return session.connectorContext?.senderName || session.user || 'user'
20
+ return (isDirectConnectorSession(session) ? session.connectorContext?.senderName : null) || session.user || 'user'
20
21
  }
21
22
 
22
23
  function slugifySegment(value: string, fallback: string): string {
@@ -28,8 +28,16 @@ function makeSession(overrides: Partial<Session> = {}): Session {
28
28
 
29
29
  test('inferSessionResetType distinguishes direct, group, and thread sessions', () => {
30
30
  assert.equal(inferSessionResetType(makeSession()), 'direct')
31
- assert.equal(inferSessionResetType(makeSession({ connectorContext: { isGroup: true } })), 'group')
32
- assert.equal(inferSessionResetType(makeSession({ connectorContext: { threadId: 'thread-1' } })), 'thread')
31
+ assert.equal(inferSessionResetType(makeSession({
32
+ name: 'connector:group',
33
+ user: 'connector',
34
+ connectorContext: { isGroup: true },
35
+ })), 'group')
36
+ assert.equal(inferSessionResetType(makeSession({
37
+ name: 'connector:thread',
38
+ user: 'connector',
39
+ connectorContext: { threadId: 'thread-1' },
40
+ })), 'thread')
33
41
  })
34
42
 
35
43
  test('resolveSessionResetPolicy falls back to type defaults', () => {
@@ -37,7 +45,13 @@ test('resolveSessionResetPolicy falls back to type defaults', () => {
37
45
  assert.equal(direct.mode, 'idle')
38
46
  assert.equal(direct.idleTimeoutSec, 12 * 60 * 60)
39
47
 
40
- const thread = resolveSessionResetPolicy({ session: makeSession({ connectorContext: { threadId: 'thread-1' } }) })
48
+ const thread = resolveSessionResetPolicy({
49
+ session: makeSession({
50
+ name: 'connector:thread',
51
+ user: 'connector',
52
+ connectorContext: { threadId: 'thread-1' },
53
+ }),
54
+ })
41
55
  assert.equal(thread.mode, 'idle')
42
56
  assert.equal(thread.idleTimeoutSec, 4 * 60 * 60)
43
57
  })
@@ -1,4 +1,5 @@
1
1
  import type { Agent, AppSettings, Session, SessionResetMode, SessionResetType } from '@/types'
2
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
2
3
 
3
4
  export interface ResolvedSessionResetPolicy {
4
5
  type: SessionResetType
@@ -140,9 +141,10 @@ export function inferSessionResetType(
140
141
  opts?: { isGroup?: boolean | null; threadId?: string | null },
141
142
  ): SessionResetType {
142
143
  if ((session?.sessionType as string | undefined) === 'orchestrated') return 'main'
143
- const threadId = opts?.threadId ?? session?.connectorContext?.threadId ?? null
144
+ const connectorContext = isDirectConnectorSession(session) ? session?.connectorContext : null
145
+ const threadId = opts?.threadId ?? connectorContext?.threadId ?? null
144
146
  if (threadId) return 'thread'
145
- const isGroup = opts?.isGroup ?? session?.connectorContext?.isGroup ?? false
147
+ const isGroup = opts?.isGroup ?? connectorContext?.isGroup ?? false
146
148
  return isGroup ? 'group' : 'direct'
147
149
  }
148
150
 
@@ -13,6 +13,7 @@ import { normalizeToolInputArgs } from './normalize-tool-args'
13
13
  import { safeJsonParseObject } from '../json-utils'
14
14
  import { tryResolvePathWithinBaseDir } from '../path-utils'
15
15
  import { dedup, errorMessage } from '@/lib/shared-utils'
16
+ import { isDirectConnectorSession } from '../connectors/session-kind'
16
17
 
17
18
  const CONNECTOR_ACTION_DEDUPE_TTL_MS = 30_000
18
19
  const CONNECTOR_TURN_SEND_TTL_MS = 180_000
@@ -341,11 +342,14 @@ function trimToString(value: unknown): string {
341
342
 
342
343
  function resolveSessionConnectorTargets(
343
344
  session: {
345
+ user?: string
346
+ name?: string
344
347
  connectorContext?: Record<string, unknown>
345
348
  messages?: Array<Record<string, unknown>>
346
349
  } | null | undefined,
347
350
  connectorId: string,
348
351
  ): Array<{ channelId: string; senderId?: string; senderName?: string }> {
352
+ if (!isDirectConnectorSession(session)) return []
349
353
  const targets: Array<{ channelId: string; senderId?: string; senderName?: string }> = []
350
354
  const seen = new Set<string>()
351
355
  const pushTarget = (target: { channelId: string; senderId?: string; senderName?: string } | null) => {
@@ -368,6 +372,7 @@ function resolveSessionConnectorTargets(
368
372
 
369
373
  const messages = Array.isArray(session?.messages) ? session.messages : []
370
374
  for (let i = messages.length - 1; i >= 0; i -= 1) {
375
+ if (messages[i]?.historyExcluded === true) continue
371
376
  const source = messages[i]?.source as Record<string, unknown> | undefined
372
377
  if (!source || trimToString(source.connectorId) !== connectorId) continue
373
378
  const channelId = trimToString(source.channelId)
@@ -386,8 +391,9 @@ function pickChannelTarget(params: {
386
391
  connector: { config?: Record<string, string> }
387
392
  connectorId: string
388
393
  to?: string
389
- recentChannelId: string | null
390
394
  currentSession?: {
395
+ user?: string
396
+ name?: string
391
397
  connectorContext?: Record<string, unknown>
392
398
  messages?: Array<Record<string, unknown>>
393
399
  } | null
@@ -421,9 +427,6 @@ function pickChannelTarget(params: {
421
427
  const outbound = connector.config?.outboundTarget?.trim()
422
428
  if (outbound) channelId = outbound
423
429
  }
424
- if (!channelId && params.recentChannelId) {
425
- channelId = params.recentChannelId
426
- }
427
430
  if (!channelId) {
428
431
  const allowed = parseCsv(connector.config?.allowedJids)
429
432
  if (allowed.length) channelId = allowed[0]
@@ -562,7 +565,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
562
565
  const {
563
566
  listRunningConnectors,
564
567
  sendConnectorMessage,
565
- getConnectorRecentChannelId,
566
568
  scheduleConnectorFollowUp,
567
569
  performConnectorMessageAction,
568
570
  } = await import('../connectors/manager')
@@ -646,6 +648,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
646
648
 
647
649
  const currentSession = bctx.resolveCurrentSession?.()
648
650
  const sessionId = bctx.ctx?.sessionId || currentSession?.id || undefined
651
+ const connectorScopedSessionId = isDirectConnectorSession(currentSession) ? sessionId : undefined
649
652
 
650
653
  if (actionName === 'send' || actionName === 'send_voice_note' || actionName === 'schedule_followup') {
651
654
  const settings = loadSettings()
@@ -662,7 +665,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
662
665
  connector,
663
666
  connectorId: selected.id,
664
667
  to,
665
- recentChannelId: getConnectorRecentChannelId(selected.id),
666
668
  currentSession,
667
669
  })
668
670
  if (target.error) return target.error
@@ -728,7 +730,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
728
730
  const sent = await sendConnectorMessage({
729
731
  connectorId: selected.id, channelId, text: '', mediaPath: voicePath, mimeType: outboundMimeType,
730
732
  fileName: fileName?.trim() || 'voicenote.mp3', caption: caption?.trim() || undefined, ptt: ptt ?? true,
731
- sessionId,
733
+ sessionId: connectorScopedSessionId,
732
734
  replyToMessageId: replyToMessageId?.trim() || undefined,
733
735
  threadId: threadId?.trim() || undefined,
734
736
  })
@@ -768,7 +770,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
768
770
  connectorId: selected.id,
769
771
  channelId,
770
772
  text: followupText,
771
- sessionId,
773
+ sessionId: connectorScopedSessionId,
772
774
  delaySec: followupDelay,
773
775
  dedupeKey: dedupeKey?.trim() || undefined,
774
776
  imageUrl: media.imageUrl,
@@ -793,7 +795,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
793
795
 
794
796
  const sent = await sendConnectorMessage({
795
797
  connectorId: selected.id, channelId, text: message?.trim() || '',
796
- sessionId,
798
+ sessionId: connectorScopedSessionId,
797
799
  imageUrl: media.imageUrl, fileUrl: media.fileUrl, mediaPath: media.mediaPath,
798
800
  mimeType: mimeType?.trim() || undefined, fileName: fileName?.trim() || undefined,
799
801
  caption: caption?.trim() || undefined,
@@ -815,7 +817,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
815
817
  connector: resolved.connector,
816
818
  connectorId: selected.id,
817
819
  to,
818
- recentChannelId: getConnectorRecentChannelId(selected.id),
819
820
  currentSession,
820
821
  })
821
822
  if (target.error) return target.error
@@ -46,6 +46,8 @@ import { safePath, findBinaryOnPath } from './context'
46
46
  import { normalizeToolInputArgs } from './normalize-tool-args'
47
47
  import type { BoardTask } from '@/types'
48
48
  import { dedup } from '@/lib/shared-utils'
49
+ import { isDirectConnectorSession } from '../connectors/session-kind'
50
+ import { buildManageSkillsDescription, executeManageSkillsAction } from './skills'
49
51
 
50
52
  // ---------------------------------------------------------------------------
51
53
  // Document helpers
@@ -258,8 +260,10 @@ function deriveScheduleFollowupTarget(sessionId: string | null | undefined): {
258
260
  }
259
261
  }
260
262
 
261
- const contextTarget = pickSourceFields(session.connectorContext || undefined)
262
- if (contextTarget.followupConnectorId && contextTarget.followupChannelId) return contextTarget
263
+ if (isDirectConnectorSession(session as { user?: string; name?: string })) {
264
+ const contextTarget = pickSourceFields(session.connectorContext || undefined)
265
+ if (contextTarget.followupConnectorId && contextTarget.followupChannelId) return contextTarget
266
+ }
263
267
 
264
268
  const messages = Array.isArray(session.messages) ? session.messages : []
265
269
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -447,12 +451,17 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
447
451
  if (ctx?.projectId) {
448
452
  description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). Omit "projectId" to link the secret to this active project.`
449
453
  }
454
+ } else if (toolKey === 'manage_skills') {
455
+ description = buildManageSkillsDescription()
450
456
  }
451
457
 
452
458
  tools.push(
453
459
  tool(
454
460
  async (rawArgs) => {
455
461
  const normalized = normalizeToolInputArgs((rawArgs ?? {}) as Record<string, unknown>)
462
+ if (toolKey === 'manage_skills') {
463
+ return executeManageSkillsAction(normalized, bctx)
464
+ }
456
465
  const action = normalized.action as string | undefined
457
466
  const id = normalized.id as string | undefined
458
467
  const data = normalized.data as string | undefined
@@ -881,11 +890,36 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
881
890
  {
882
891
  name: toolKey,
883
892
  description,
884
- schema: z.object({
885
- action: z.enum(['list', 'get', 'create', 'update', 'delete']).describe('The CRUD action to perform'),
886
- id: z.string().optional().describe('Resource ID (required for get, update, delete)'),
887
- data: z.string().optional().describe('JSON string of fields for create/update'),
888
- }).passthrough(),
893
+ schema: toolKey === 'manage_skills'
894
+ ? z.object({
895
+ action: z.enum([
896
+ 'list',
897
+ 'get',
898
+ 'create',
899
+ 'update',
900
+ 'delete',
901
+ 'status',
902
+ 'search_available',
903
+ 'recommend_for_task',
904
+ 'attach',
905
+ 'install',
906
+ ]).describe('The manage_skills action to perform'),
907
+ id: z.string().optional().describe('Stored skill ID or runtime skill selector'),
908
+ skillId: z.string().optional().describe('Alternate skill selector'),
909
+ name: z.string().optional().describe('Skill name or marketplace name'),
910
+ query: z.string().optional().describe('Search query or task description'),
911
+ task: z.string().optional().describe('Task description for skill recommendation'),
912
+ url: z.string().optional().describe('Remote skill URL for install'),
913
+ approvalId: z.string().optional().describe('Approved install request id'),
914
+ attach: z.boolean().optional().describe('Attach the skill to the current agent after install'),
915
+ agentId: z.string().optional().describe('Target agent id for attach'),
916
+ data: z.string().optional().describe('JSON string of fields for create/update'),
917
+ }).passthrough()
918
+ : z.object({
919
+ action: z.enum(['list', 'get', 'create', 'update', 'delete']).describe('The CRUD action to perform'),
920
+ id: z.string().optional().describe('Resource ID (required for get, update, delete)'),
921
+ data: z.string().optional().describe('JSON string of fields for create/update'),
922
+ }).passthrough(),
889
923
  },
890
924
  ),
891
925
  )
@@ -45,6 +45,7 @@ import { buildExtractTools } from './extract'
45
45
  import { buildTableTools } from './table'
46
46
  import { buildCrawlTools } from './crawl'
47
47
  import { buildGoogleWorkspaceTools } from './google-workspace'
48
+ import { buildSkillRuntimeTools } from './skill-runtime'
48
49
  import './connector'
49
50
  import { normalizeToolInputArgs } from './normalize-tool-args'
50
51
  import { enforceFileAccessPolicy } from './file-access-policy'
@@ -172,6 +173,7 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
172
173
  ['calendar', buildCalendarTools],
173
174
  ['replicate', buildReplicateTools],
174
175
  ['google_workspace', buildGoogleWorkspaceTools],
176
+ ['use_skill', buildSkillRuntimeTools],
175
177
  ['mailbox', buildMailboxTools],
176
178
  ['ask_human', buildHumanLoopTools],
177
179
  ['document', buildDocumentTools],