@swarmclawai/swarmclaw 0.9.2 → 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 (75) 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/agents/page.tsx +2 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  6. package/src/app/api/clawhub/install/route.ts +2 -0
  7. package/src/app/api/skills/[id]/route.ts +4 -0
  8. package/src/app/api/skills/route.ts +4 -0
  9. package/src/app/globals.css +28 -0
  10. package/src/app/home/page.tsx +11 -0
  11. package/src/app/settings/page.tsx +12 -5
  12. package/src/components/agents/agent-sheet.tsx +5 -5
  13. package/src/components/connectors/connector-list.tsx +2 -5
  14. package/src/components/logs/log-list.tsx +2 -5
  15. package/src/components/providers/provider-list.tsx +2 -5
  16. package/src/components/runs/run-list.tsx +2 -6
  17. package/src/components/schedules/schedule-list.tsx +7 -1
  18. package/src/components/ui/full-screen-loader.tsx +0 -29
  19. package/src/components/ui/page-loader.tsx +69 -0
  20. package/src/lib/runtime/runtime-loop.ts +21 -1
  21. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  22. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  23. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  24. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  25. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  26. package/src/lib/server/agents/orchestrator.ts +11 -7
  27. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  28. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  29. package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
  30. package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
  31. package/src/lib/server/chat-execution/chat-execution.ts +116 -29
  32. package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
  33. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +67 -76
  34. package/src/lib/server/chat-execution/stream-agent-chat.ts +119 -110
  35. package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
  36. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  37. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  38. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  39. package/src/lib/server/connectors/manager.test.ts +504 -73
  40. package/src/lib/server/connectors/manager.ts +41 -10
  41. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  42. package/src/lib/server/connectors/session-kind.ts +7 -0
  43. package/src/lib/server/connectors/session.test.ts +104 -0
  44. package/src/lib/server/connectors/session.ts +5 -2
  45. package/src/lib/server/identity-continuity.test.ts +4 -3
  46. package/src/lib/server/identity-continuity.ts +8 -4
  47. package/src/lib/server/memory/memory-policy.test.ts +5 -15
  48. package/src/lib/server/memory/memory-policy.ts +11 -41
  49. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  50. package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
  51. package/src/lib/server/runtime/heartbeat-service.ts +5 -1
  52. package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
  53. package/src/lib/server/runtime/runtime-settings.ts +4 -0
  54. package/src/lib/server/runtime/session-run-manager.ts +2 -0
  55. package/src/lib/server/session-reset-policy.test.ts +17 -3
  56. package/src/lib/server/session-reset-policy.ts +4 -2
  57. package/src/lib/server/session-tools/connector.ts +11 -10
  58. package/src/lib/server/session-tools/crud.ts +41 -7
  59. package/src/lib/server/session-tools/delegate.ts +3 -3
  60. package/src/lib/server/session-tools/index.ts +2 -0
  61. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  62. package/src/lib/server/session-tools/memory.ts +209 -48
  63. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  64. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  65. package/src/lib/server/session-tools/skills.ts +575 -0
  66. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  67. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  68. package/src/lib/server/skills/skill-discovery.ts +4 -0
  69. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  70. package/src/lib/server/skills/skills-normalize.ts +93 -1
  71. package/src/lib/server/storage.ts +1 -1
  72. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  73. package/src/lib/server/tasks/task-followups.ts +88 -13
  74. package/src/types/index.ts +30 -2
  75. package/src/views/settings/section-runtime-loop.tsx +38 -0
@@ -10,6 +10,7 @@ import {
10
10
  getToolEventsSnapshotKey,
11
11
  hasPersistableAssistantPayload,
12
12
  parseUsdLimit,
13
+ pruneOldHeartbeatMessages,
13
14
  shouldAutoRouteHeartbeatAlerts,
14
15
  shouldPersistInboundUserMessage,
15
16
  shouldReplaceRecentAssistantMessage,
@@ -478,3 +479,58 @@ describe('estimateConversationTone', () => {
478
479
  assert.equal(estimateConversationTone(''), 'neutral')
479
480
  })
480
481
  })
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // pruneOldHeartbeatMessages
485
+ // ---------------------------------------------------------------------------
486
+ describe('pruneOldHeartbeatMessages', () => {
487
+ it('removes old heartbeat messages keeping only the most recent 2', () => {
488
+ const messages: Message[] = [
489
+ { role: 'user', text: 'hi', time: 1 },
490
+ { role: 'assistant', text: 'alert 1', time: 2, kind: 'heartbeat' },
491
+ { role: 'assistant', text: 'alert 2', time: 3, kind: 'heartbeat' },
492
+ { role: 'assistant', text: 'real reply', time: 4, kind: 'chat' },
493
+ { role: 'assistant', text: 'alert 3', time: 5, kind: 'heartbeat' },
494
+ { role: 'assistant', text: 'alert 4', time: 6, kind: 'heartbeat' },
495
+ ]
496
+ const removed = pruneOldHeartbeatMessages(messages)
497
+ assert.equal(removed, 2)
498
+ assert.equal(messages.length, 4)
499
+ // Only the last 2 heartbeat messages remain
500
+ const heartbeats = messages.filter((m) => m.kind === 'heartbeat')
501
+ assert.equal(heartbeats.length, 2)
502
+ assert.equal(heartbeats[0].text, 'alert 3')
503
+ assert.equal(heartbeats[1].text, 'alert 4')
504
+ })
505
+
506
+ it('does not remove anything when count is at or below maxKeep', () => {
507
+ const messages: Message[] = [
508
+ { role: 'assistant', text: 'alert 1', time: 1, kind: 'heartbeat' },
509
+ { role: 'user', text: 'hi', time: 2 },
510
+ { role: 'assistant', text: 'alert 2', time: 3, kind: 'heartbeat' },
511
+ ]
512
+ assert.equal(pruneOldHeartbeatMessages(messages), 0)
513
+ assert.equal(messages.length, 3)
514
+ })
515
+
516
+ it('respects custom maxKeep value', () => {
517
+ const messages: Message[] = [
518
+ { role: 'assistant', text: 'hb1', time: 1, kind: 'heartbeat' },
519
+ { role: 'assistant', text: 'hb2', time: 2, kind: 'heartbeat' },
520
+ { role: 'assistant', text: 'hb3', time: 3, kind: 'heartbeat' },
521
+ ]
522
+ assert.equal(pruneOldHeartbeatMessages(messages, 1), 2)
523
+ assert.equal(messages.length, 1)
524
+ assert.equal(messages[0].text, 'hb3')
525
+ })
526
+
527
+ it('does not touch non-heartbeat messages', () => {
528
+ const messages: Message[] = [
529
+ { role: 'user', text: 'a', time: 1 },
530
+ { role: 'assistant', text: 'b', time: 2, kind: 'chat' },
531
+ { role: 'assistant', text: 'c', time: 3 },
532
+ ]
533
+ assert.equal(pruneOldHeartbeatMessages(messages), 0)
534
+ assert.equal(messages.length, 3)
535
+ })
536
+ })
@@ -522,6 +522,30 @@ export function classifyHeartbeatResponse(text: string, ackMaxChars: number, had
522
522
  return stripped.length < cleaned.length ? 'strip' : 'keep'
523
523
  }
524
524
 
525
+ /**
526
+ * Prune old heartbeat messages from the transcript to prevent context bloat.
527
+ * Keeps only the most recent `maxKeep` heartbeat assistant messages.
528
+ * Returns the number of messages removed.
529
+ */
530
+ export function pruneOldHeartbeatMessages(messages: Message[], maxKeep = 2): number {
531
+ const heartbeatIndices: number[] = []
532
+ for (let i = 0; i < messages.length; i++) {
533
+ if (messages[i].role === 'assistant' && messages[i].kind === 'heartbeat') {
534
+ heartbeatIndices.push(i)
535
+ }
536
+ }
537
+ if (heartbeatIndices.length <= maxKeep) return 0
538
+ const toRemove = new Set(heartbeatIndices.slice(0, heartbeatIndices.length - maxKeep))
539
+ let removed = 0
540
+ for (let i = messages.length - 1; i >= 0; i--) {
541
+ if (toRemove.has(i)) {
542
+ messages.splice(i, 1)
543
+ removed++
544
+ }
545
+ }
546
+ return removed
547
+ }
548
+
525
549
  export function estimateConversationTone(text: string): string {
526
550
  const t = text || ''
527
551
  if (/```/.test(t) || /\b(function|const|let|var|import|export|class|interface|async|await|return)\b/.test(t)) return 'technical'
@@ -29,6 +29,7 @@ import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agent
29
29
  import { resolveSessionToolPolicy } from '@/lib/server/tool-capability-policy'
30
30
  import { buildCurrentDateTimePromptContext } from '@/lib/server/prompt-runtime-context'
31
31
  import { buildWorkspaceContext } from '@/lib/server/workspace-context'
32
+ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
32
33
  import { resolveImagePath } from '@/lib/server/resolve-image'
33
34
  import {
34
35
  applyContextClearBoundary,
@@ -48,6 +49,7 @@ import {
48
49
  getTodaySpendUsd,
49
50
  classifyHeartbeatResponse,
50
51
  estimateConversationTone,
52
+ pruneOldHeartbeatMessages,
51
53
  } from '@/lib/server/chat-execution/chat-execution-utils'
52
54
  import { runPostLlmToolRouting } from '@/lib/server/chat-execution/chat-turn-tool-routing'
53
55
  import {
@@ -66,6 +68,7 @@ import { evaluateSessionFreshness, resetSessionRuntime, resolveSessionResetPolic
66
68
  import { pruneStreamingAssistantArtifacts, upsertStreamingAssistantArtifact } from '@/lib/chat/chat-streaming-state'
67
69
  import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
68
70
  import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/agent-availability'
71
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
69
72
  import { errorMessage as toErrorMessage } from '@/lib/shared-utils'
70
73
  import { listUniversalToolAccessPluginIds } from '@/lib/server/universal-tool-access'
71
74
 
@@ -108,6 +111,24 @@ export function buildEnabledToolsAutonomyGuidance(): string[] {
108
111
  ]
109
112
  }
110
113
 
114
+ function resolveHeartbeatLastConnectorTarget(session: Session | null | undefined): {
115
+ connectorId?: string
116
+ channelId: string
117
+ } | null {
118
+ if (!isDirectConnectorSession(session)) return null
119
+ const connectorId = typeof session?.connectorContext?.connectorId === 'string'
120
+ ? session.connectorContext.connectorId.trim()
121
+ : ''
122
+ const channelId = typeof session?.connectorContext?.channelId === 'string'
123
+ ? session.connectorContext.channelId.trim()
124
+ : ''
125
+ if (!channelId) return null
126
+ return {
127
+ connectorId: connectorId || undefined,
128
+ channelId,
129
+ }
130
+ }
131
+
111
132
  interface SessionWithCredentials {
112
133
  credentialId?: string | null
113
134
  }
@@ -129,7 +150,14 @@ export interface ExecuteChatTurnInput {
129
150
  signal?: AbortSignal
130
151
  onEvent?: (event: SSEEvent) => void
131
152
  modelOverride?: string
132
- heartbeatConfig?: { ackMaxChars: number; showOk: boolean; showAlerts: boolean; target: string | null }
153
+ heartbeatConfig?: {
154
+ ackMaxChars: number
155
+ showOk: boolean
156
+ showAlerts: boolean
157
+ target: string | null
158
+ lightContext?: boolean
159
+ deliveryMode?: 'default' | 'tool_only'
160
+ }
133
161
  replyToId?: string
134
162
  }
135
163
 
@@ -421,6 +449,10 @@ function syncSessionFromAgent(sessionId: string): void {
421
449
  session.openclawAgentId = desiredOpenClawAgentId
422
450
  changed = true
423
451
  }
452
+ if (session.connectorContext) {
453
+ session.connectorContext = undefined
454
+ changed = true
455
+ }
424
456
  }
425
457
 
426
458
  if (changed) {
@@ -429,6 +461,29 @@ function syncSessionFromAgent(sessionId: string): void {
429
461
  }
430
462
  }
431
463
 
464
+ /**
465
+ * Build a minimal system prompt for lightweight heartbeat context.
466
+ * Strips conversation history, skills, tool discipline, and workspace context.
467
+ * Keeps identity, datetime, and heartbeat guidance for correct routing.
468
+ */
469
+ function buildLightHeartbeatSystemPrompt(session: Session): string | undefined {
470
+ if (!session.agentId) return undefined
471
+ const agents = loadAgents()
472
+ const agent = agents[session.agentId]
473
+ if (!agent) return undefined
474
+
475
+ const parts: string[] = []
476
+ parts.push(`## Identity\nName: ${agent.name}`)
477
+ if (agent.description) parts.push(`Description: ${agent.description}`)
478
+ parts.push(buildCurrentDateTimePromptContext())
479
+ if (agent.soul) parts.push(`## Soul\n${agent.soul.slice(0, 300)}`)
480
+ parts.push([
481
+ '## Heartbeats',
482
+ 'You run on an autonomous heartbeat. If you receive a heartbeat poll and nothing needs attention, reply exactly: HEARTBEAT_OK',
483
+ ].join('\n'))
484
+ return parts.join('\n\n')
485
+ }
486
+
432
487
  function buildAgentSystemPrompt(session: Session): string | undefined {
433
488
  if (!session.agentId) return undefined
434
489
  const agents = loadAgents()
@@ -472,13 +527,16 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
472
527
  if (agent.systemPrompt) parts.push(`## System Prompt\n${agent.systemPrompt}`)
473
528
 
474
529
  // 5. Skills (SwarmClaw Core)
475
- if (agent.skillIds?.length) {
476
- const allSkills = loadSkills()
477
- for (const skillId of agent.skillIds) {
478
- const skill = allSkills[skillId]
479
- if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
480
- }
481
- }
530
+ try {
531
+ const runtimeSkills = resolveRuntimeSkills({
532
+ cwd: session.cwd,
533
+ enabledPlugins,
534
+ agentSkillIds: agent.skillIds || [],
535
+ storedSkills: loadSkills(),
536
+ selectedSkillId: session.skillRuntimeState?.selectedSkillId || null,
537
+ })
538
+ parts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
539
+ } catch { /* non-critical */ }
482
540
 
483
541
  // 5b. Workspace context files (HEARTBEAT.md, IDENTITY.md, AGENTS.md, etc.)
484
542
  try {
@@ -601,6 +659,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
601
659
  )
602
660
  const isHeartbeatRun = isInternalHeartbeatRun(internal, source)
603
661
  const isAutonomousInternalRun = internal && source !== 'chat'
662
+ const heartbeatLightContext = isHeartbeatRun && !!input.heartbeatConfig?.lightContext
604
663
  const isAutoRunNoHistory = isHeartbeatRun
605
664
  const heartbeatStatusOnly = false
606
665
  if (shouldApplySessionFreshnessReset(source)) {
@@ -803,7 +862,10 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
803
862
  // including identity, soul, skills, tool discipline, and execution policy.
804
863
  // Only build the standalone system prompt for the direct-provider (no LangGraph) path
805
864
  // to avoid duplicating tool discipline, operating guidance, and capability sections.
806
- const systemPrompt = hasPlugins ? undefined : buildAgentSystemPrompt(session)
865
+ // lightContext mode uses a minimal prompt for both paths to reduce token cost.
866
+ const systemPrompt = heartbeatLightContext
867
+ ? buildLightHeartbeatSystemPrompt(session)
868
+ : (hasPlugins ? undefined : buildAgentSystemPrompt(session))
807
869
  const toolEvents: MessageToolEvent[] = []
808
870
  const streamErrors: string[] = []
809
871
  const accumulatedUsage = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }
@@ -967,8 +1029,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
967
1029
  // Heartbeat runs get a small tail of recent messages so the agent can see
968
1030
  // prior findings and avoid repeating the same searches. Full history is
969
1031
  // skipped to avoid blowing the context window on long-lived sessions.
1032
+ // lightContext mode skips history entirely for maximum token savings.
970
1033
  const heartbeatHistory = isAutoRunNoHistory
971
- ? getSessionMessages(sessionId).slice(-6)
1034
+ ? (heartbeatLightContext ? [] : getSessionMessages(sessionId).slice(-6))
972
1035
  : undefined
973
1036
 
974
1037
  console.log(`[chat-execution] provider=${providerType}, hasPlugins=${hasPlugins}, localOpenClawNative=${useLocalOpenClawNativeRuntime}, imagePath=${resolvedImagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, plugins=${enabledSessionPlugins.length}`)
@@ -988,7 +1051,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
988
1051
  fullResponse = result.finalResponse || result.fullText
989
1052
  } else {
990
1053
  const directHistorySnapshot = isAutoRunNoHistory
991
- ? getSessionMessages(sessionId).slice(-6)
1054
+ ? (heartbeatLightContext ? [] : getSessionMessages(sessionId).slice(-6))
992
1055
  : applyContextClearBoundary(getSessionMessages(sessionId))
993
1056
  responseCacheInput = {
994
1057
  provider: providerType,
@@ -1186,6 +1249,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1186
1249
  && (Date.now() - prevSentAt) < 24 * 60 * 60 * 1000
1187
1250
  if (isDuplicate) {
1188
1251
  heartbeatClassification = 'suppress'
1252
+ log.info('heartbeat', `Duplicate heartbeat suppressed for session ${sessionId} (same text within 24h)`)
1189
1253
  }
1190
1254
  }
1191
1255
  }
@@ -1198,6 +1262,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1198
1262
  const shouldPersistAssistant = !hiddenControlOnly
1199
1263
  && hasPersistableAssistantPayload(persistedText, thinkingText, persistedToolEvents)
1200
1264
  && heartbeatClassification !== 'suppress'
1265
+ && !(isHeartbeatRun && heartbeatConfig?.deliveryMode === 'tool_only' && !isDirectConnectorSession(session))
1201
1266
 
1202
1267
  const normalizeResumeId = (value: unknown): string | null =>
1203
1268
  typeof value === 'string' && value.trim() ? value.trim() : null
@@ -1206,6 +1271,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1206
1271
  const current = fresh[sessionId]
1207
1272
  if (current) {
1208
1273
  current.messages = Array.isArray(current.messages) ? current.messages : []
1274
+ if (!isDirectConnectorSession(current) && current.connectorContext) {
1275
+ current.connectorContext = undefined
1276
+ }
1209
1277
  const currentAgent = current.agentId ? loadAgents()[current.agentId] : null
1210
1278
  pruneStreamingAssistantArtifacts(current.messages, {
1211
1279
  minIndex: runMessageStartIndex,
@@ -1279,18 +1347,22 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1279
1347
  }
1280
1348
 
1281
1349
  // Target routing for non-suppressed heartbeat alerts
1282
- if (isHeartbeatRun && heartbeatConfig?.target && heartbeatConfig.target !== 'none' && heartbeatConfig.showAlerts !== false) {
1350
+ if (
1351
+ isHeartbeatRun
1352
+ && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1353
+ && heartbeatConfig?.target
1354
+ && heartbeatConfig.target !== 'none'
1355
+ ) {
1283
1356
  try {
1284
1357
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1285
- const { listRunningConnectors, sendConnectorMessage } = require('../connectors/manager')
1358
+ const { sendConnectorMessage } = require('../connectors/manager')
1286
1359
  let connectorId: string | undefined
1287
1360
  let channelId: string | undefined
1288
1361
  if (heartbeatConfig.target === 'last') {
1289
- const running = listRunningConnectors()
1290
- const first = running.find((c: { recentChannelId?: string }) => c.recentChannelId)
1291
- if (first) {
1292
- connectorId = first.id
1293
- channelId = first.recentChannelId
1362
+ const lastTarget = resolveHeartbeatLastConnectorTarget(current)
1363
+ if (lastTarget) {
1364
+ connectorId = lastTarget.connectorId
1365
+ channelId = lastTarget.channelId
1294
1366
  }
1295
1367
  } else if (heartbeatConfig.target.includes(':')) {
1296
1368
  const [cId, chId] = heartbeatConfig.target.split(':', 2)
@@ -1309,19 +1381,25 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1309
1381
 
1310
1382
  // Auto-discover connectors linked to this agent when no explicit target is set
1311
1383
  // Skip if a real inbound message was handled recently — the agent just responded to it
1312
- if (isHeartbeatRun && !heartbeatConfig?.target && heartbeatConfig?.showAlerts !== false && session.agentId) {
1313
- const recentInbound = session.connectorContext?.lastInboundAt
1314
- && (Date.now() - session.connectorContext.lastInboundAt) < 60_000
1315
- if (!recentInbound) {
1384
+ if (
1385
+ isHeartbeatRun
1386
+ && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1387
+ && !heartbeatConfig?.target
1388
+ && isDirectConnectorSession(current)
1389
+ ) {
1390
+ const recentInbound = current.connectorContext?.lastInboundAt
1391
+ && (Date.now() - current.connectorContext.lastInboundAt) < 60_000
1392
+ const connectorId = typeof current.connectorContext?.connectorId === 'string'
1393
+ ? current.connectorContext.connectorId.trim()
1394
+ : ''
1395
+ const channelId = typeof current.connectorContext?.channelId === 'string'
1396
+ ? current.connectorContext.channelId.trim()
1397
+ : ''
1398
+ if (!recentInbound && channelId) {
1316
1399
  try {
1317
1400
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1318
- const { listRunningConnectors: listRunning, sendConnectorMessage: sendMsg } = require('../connectors/manager')
1319
- const agentConnectors = listRunning().filter((c: { agentId: string | null; recentChannelId: string | null; supportsSend: boolean }) =>
1320
- c.agentId === session.agentId && c.recentChannelId && c.supportsSend
1321
- )
1322
- for (const conn of agentConnectors) {
1323
- sendMsg({ connectorId: conn.id, channelId: conn.recentChannelId, text: persistedText }).catch(() => {})
1324
- }
1401
+ const { sendConnectorMessage: sendMsg } = require('../connectors/manager')
1402
+ sendMsg({ connectorId: connectorId || undefined, channelId, text: persistedText }).catch(() => {})
1325
1403
  } catch {
1326
1404
  // Best effort — connector manager may not be loaded
1327
1405
  }
@@ -1332,6 +1410,15 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1332
1410
  pruneSuppressedHeartbeatStreamMessage(current.messages)
1333
1411
  }
1334
1412
 
1413
+ // P1: Prune old heartbeat messages to prevent context bloat.
1414
+ // Long-running agents accumulate ~48 no-op messages/day; keep only the most recent 2.
1415
+ if (isHeartbeatRun) {
1416
+ const pruned = pruneOldHeartbeatMessages(current.messages)
1417
+ if (pruned > 0) {
1418
+ log.info('heartbeat', `Pruned ${pruned} old heartbeat message(s) from session ${sessionId}`)
1419
+ }
1420
+ }
1421
+
1335
1422
  // Fire afterChatTurn hook for all enabled plugins (memory auto-save, logging, etc.)
1336
1423
  try {
1337
1424
  await getPluginManager().runHook('afterChatTurn', {
@@ -1,14 +1,11 @@
1
1
  import type { MessageToolEvent } from '@/types'
2
2
  import { canonicalizePluginId } from '@/lib/server/tool-aliases'
3
3
  import { extractSuggestions } from '@/lib/server/suggestions'
4
- import { isDirectMemoryWriteRequest } from '@/lib/server/memory/memory-policy'
5
4
  import {
6
- isBroadGoal,
7
5
  looksLikeExternalWalletTask,
8
- looksLikeOpenEndedDeliverableTask,
9
6
  } from '@/lib/server/chat-execution/stream-continuation'
10
7
 
11
- const EXPLICIT_ARTIFACT_OUTPUT_RE = /\b(?:save|write|output|export)\b[^.!?\n]{0,80}\b(?:to|as|at|in)\b[^.!?\n]{0,60}(\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|~\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|\.\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|[a-z0-9._/-]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)\b)/i
8
+ const EXPLICIT_ARTIFACT_OUTPUT_RE = /\b(?:save|write|output|export|create|generate)\b[^.!?\n]{0,80}\b(?:to|as|at|in)\b[^.!?\n]{0,60}(\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|~\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|\.\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|[a-z0-9._/-]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)\b)/i
12
9
 
13
10
  export function isLikelyToolErrorOutput(output: string): boolean {
14
11
  const trimmed = String(output || '').trim()
@@ -168,37 +165,3 @@ export function compactThreadRecallText(text: string, maxChars = 180): string {
168
165
  return compact.length > maxChars ? `${compact.slice(0, maxChars - 3)}...` : compact
169
166
  }
170
167
 
171
- const DIRECT_MEMORY_WRITE_CONFIRMATION_ONLY_RE = /\b(?:then|and then|after that)?\s*(?:confirm|recap|repeat|summarize|tell me|say)\b[\s\S]{0,120}\b(?:stored|saved|updated|remembered|wrote|write)\b/i
172
- const DIRECT_MEMORY_WRITE_EXTRA_ACTION_RE = /\b(?:then|and then|after that|also)\b[\s\S]{0,160}\b(?:write|create|send|email|message|delegate|research|search|browse|open|edit|build|schedule|plan|review|analy[sz]e)\b/i
173
-
174
- export function isNarrowDirectMemoryWriteTurn(message: string): boolean {
175
- const trimmed = String(message || '').trim()
176
- if (!trimmed || !isDirectMemoryWriteRequest(trimmed)) return false
177
- if (looksLikeOpenEndedDeliverableTask(trimmed)) return false
178
- if (DIRECT_MEMORY_WRITE_EXTRA_ACTION_RE.test(trimmed) && !DIRECT_MEMORY_WRITE_CONFIRMATION_ONLY_RE.test(trimmed)) {
179
- return false
180
- }
181
- return !isBroadGoal(trimmed) || DIRECT_MEMORY_WRITE_CONFIRMATION_ONLY_RE.test(trimmed) || !/[?]$/.test(trimmed)
182
- }
183
-
184
- const CURRENT_THREAD_RECALL_BLOCKED_TOOL_IDS = new Set([
185
- 'memory',
186
- 'manage_sessions',
187
- 'web',
188
- 'context_mgmt',
189
- ])
190
-
191
- export function shouldAllowToolForCurrentThreadRecall(toolName: string): boolean {
192
- const canonicalToolName = canonicalizePluginId(toolName) || toolName.trim().toLowerCase()
193
- return !CURRENT_THREAD_RECALL_BLOCKED_TOOL_IDS.has(canonicalToolName)
194
- }
195
-
196
- const DIRECT_MEMORY_WRITE_ALLOWED_TOOL_IDS = new Set([
197
- 'memory_store',
198
- 'memory_update',
199
- ])
200
-
201
- export function shouldAllowToolForDirectMemoryWrite(toolName: string): boolean {
202
- const rawToolName = toolName.trim().toLowerCase()
203
- return DIRECT_MEMORY_WRITE_ALLOWED_TOOL_IDS.has(rawToolName)
204
- }