@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
@@ -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.`)
@@ -1534,7 +1565,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1534
1565
  const threadContextBlock = buildConnectorThreadContextBlock(msg, { isFirstThreadTurn: wasCreated })
1535
1566
  if (threadContextBlock) promptParts.push(threadContextBlock)
1536
1567
  // Add connector context
1537
- 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.
1568
+ promptParts.push(`\nYou are receiving messages via ${msg.platform}. The user "${msg.senderName}" (ID: ${msg.senderId}) is messaging from channel "${msg.channelName || msg.channelId}". Respond naturally and conversationally.
1538
1569
 
1539
1570
  ## Response Style
1540
1571
  Be action-first and autonomous: when the user gives an instruction, execute it instead of asking routine follow-up questions.
@@ -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}.`
@@ -2,7 +2,6 @@ import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import {
4
4
  inferAutomaticMemoryCategory,
5
- isDirectMemoryWriteRequest,
6
5
  isCurrentThreadRecallRequest,
7
6
  normalizeMemoryCategory,
8
7
  shouldAutoCaptureMemory,
@@ -42,17 +41,6 @@ test('isCurrentThreadRecallRequest detects same-thread recall without matching s
42
41
  )
43
42
  })
44
43
 
45
- test('isDirectMemoryWriteRequest detects remember-and-confirm turns without matching recall questions', () => {
46
- assert.equal(
47
- isDirectMemoryWriteRequest('Remember that my favorite programming language is Rust and I prefer functional programming patterns. Then confirm what you just stored.'),
48
- true,
49
- )
50
- assert.equal(
51
- isDirectMemoryWriteRequest('What preferences did I tell you earlier in this conversation?'),
52
- false,
53
- )
54
- })
55
-
56
44
  test('shouldAutoCaptureMemory filters noisy turns', () => {
57
45
  assert.equal(shouldAutoCaptureMemory({ message: 'thanks', response: 'Happy to help with that.' }), false)
58
46
  assert.equal(shouldAutoCaptureMemory({ message: 'Please save this to memory', response: 'Stored memory "note".' }), false)
@@ -63,13 +51,15 @@ test('shouldAutoCaptureMemory filters noisy turns', () => {
63
51
  }), true)
64
52
  })
65
53
 
66
- test('inferAutomaticMemoryCategory picks a stable automatic bucket', () => {
54
+ test('inferAutomaticMemoryCategory falls back to knowledge/facts without content-sniffing', () => {
55
+ // Content-sniffing regex removed — the agent picks categories via guidance.
56
+ // inferAutomaticMemoryCategory (called with category "note") should fall through.
67
57
  assert.equal(
68
58
  inferAutomaticMemoryCategory('The user prefers direct status updates.', 'I will keep future updates terse and direct.'),
69
- 'identity/preferences',
59
+ 'knowledge/facts',
70
60
  )
71
61
  assert.equal(
72
62
  inferAutomaticMemoryCategory('We decided to ship the GitHub import first.', 'Decision locked for the next milestone.'),
73
- 'projects/decisions',
63
+ 'knowledge/facts',
74
64
  )
75
65
  })
@@ -6,7 +6,7 @@ const MEMORY_META_RE = /\b(?:remember|memory|memorize|store this|save this|forge
6
6
  const LOW_SIGNAL_RESPONSE_RE = /^(?:HEARTBEAT_OK|NO_MESSAGE)\b/i
7
7
  const CURRENT_THREAD_RECALL_MARKER_RE = /\b(?:this conversation|this chat|this thread|current conversation|current chat|current thread|same thread|same chat|same conversation|earlier in (?:this )?(?:conversation|chat|thread)|from (?:this|our) (?:conversation|chat|thread)|you just stored|you just said|we just discussed|we just decided)\b/i
8
8
  const CURRENT_THREAD_RECALL_INTENT_RE = /\b(?:what|which|who|when|where|did|remind|recap|summarize|repeat|list|tell me|answer|confirm|recall|mention)\b/i
9
- const DIRECT_MEMORY_WRITE_MARKER_RE = /\b(?:remember|memorize|store|save|write to memory|add to memory|update.*memory|correct.*memory)\b/i
9
+ const DIRECT_MEMORY_WRITE_MARKER_RE = /\b(?:remember|memorize|store (?:this|that|the fact|it)|save (?:this|that|the fact|it) (?:to|in) memory|write to memory|add to memory|update.*memory|correct.*memory)\b/i
10
10
  const DIRECT_MEMORY_WRITE_FOLLOWUP_RE = /\b(?:confirm|recap|repeat|summarize|what you just stored|what you saved|what you updated)\b/i
11
11
 
12
12
  function normalizeWhitespace(value: string): string {
@@ -36,16 +36,6 @@ export function isCurrentThreadRecallRequest(message: string): boolean {
36
36
  return CURRENT_THREAD_RECALL_INTENT_RE.test(trimmed) || /\?\s*$/.test(trimmed)
37
37
  }
38
38
 
39
- export function isDirectMemoryWriteRequest(message: string): boolean {
40
- const trimmed = normalizeWhitespace(message)
41
- if (!trimmed) return false
42
- const directWriteLike = DIRECT_MEMORY_WRITE_MARKER_RE.test(trimmed)
43
- if (!directWriteLike) return false
44
- if (/\?\s*$/.test(trimmed) && !DIRECT_MEMORY_WRITE_FOLLOWUP_RE.test(trimmed)) return false
45
- if (isCurrentThreadRecallRequest(trimmed) && !DIRECT_MEMORY_WRITE_FOLLOWUP_RE.test(trimmed)) return false
46
- return true
47
- }
48
-
49
39
  export function shouldAutoCaptureMemoryTurn(message: string, response: string): boolean {
50
40
  const normalizedMessage = normalizeWhitespace(message)
51
41
  const normalizedResponse = normalizeWhitespace(response)
@@ -67,19 +57,20 @@ export function shouldAutoCaptureMemory(
67
57
  return shouldAutoCaptureMemoryTurn(input.message || '', input.response || '')
68
58
  }
69
59
 
70
- export function normalizeMemoryCategory(
71
- input: string | null | undefined,
72
- title: string | null | undefined,
73
- content: string | null | undefined,
74
- ): string {
60
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
61
+ export function normalizeMemoryCategory(input: string | null | undefined, _title?: string | null, _content?: string | null): string {
75
62
  const explicit = lower(input)
76
- const sample = `${lower(title)}\n${lower(content)}`
77
63
 
78
64
  const mapExplicit = (value: string): string | null => {
79
65
  if (!value || value === 'note' || value === 'notes') return null
80
66
  if (['preference', 'preferences', 'likes', 'dislikes'].includes(value)) return 'identity/preferences'
81
67
  if (['identity', 'profile', 'persona'].includes(value)) return 'identity/profile'
82
68
  if (['relationship', 'relationships', 'people'].includes(value)) return 'identity/relationships'
69
+ if (['contact', 'contacts'].includes(value)) return 'identity/contacts'
70
+ if (['routine', 'routines', 'schedule', 'habit', 'habits'].includes(value)) return 'identity/routines'
71
+ if (['event', 'events', 'life event', 'life events', 'significant', 'milestone'].includes(value)) return 'identity/events'
72
+ if (['goal', 'goals', 'objective', 'objectives', 'target', 'targets'].includes(value)) return 'identity/goals'
73
+ if (['instruction', 'instructions', 'directive', 'directives', 'standing order', 'rule', 'rules'].includes(value)) return 'knowledge/instructions'
83
74
  if (['decision', 'decisions', 'choice'].includes(value)) return 'projects/decisions'
84
75
  if (['learning', 'learnings', 'lesson', 'lessons'].includes(value)) return 'projects/learnings'
85
76
  if (['project', 'projects', 'task', 'tasks'].includes(value)) return 'projects/context'
@@ -94,30 +85,9 @@ export function normalizeMemoryCategory(
94
85
  const explicitMapped = mapExplicit(explicit)
95
86
  if (explicitMapped) return explicitMapped
96
87
 
97
- if (/\b(?:prefer(?:s|ence)?|likes?|dislikes?|favorite|timezone|pronouns|call me)\b/.test(sample)) {
98
- return 'identity/preferences'
99
- }
100
- if (/\b(?:wife|husband|partner|friend|manager|teammate|client|customer|relationship)\b/.test(sample)) {
101
- return 'identity/relationships'
102
- }
103
- if (/\b(?:decided|decision|approved|picked|selected|going with|will use)\b/.test(sample)) {
104
- return 'projects/decisions'
105
- }
106
- if (/\b(?:learned|lesson|fixed|solved|root cause|failure|bug|regression|postmortem)\b/.test(sample)) {
107
- return 'projects/learnings'
108
- }
109
- if (/\b(?:error|incident|stack trace|exception|crash)\b/.test(sample)) {
110
- return 'execution/errors'
111
- }
112
- if (/\b(?:project|repo|repository|ticket|task|milestone|deadline|roadmap)\b/.test(sample)) {
113
- return 'projects/context'
114
- }
115
- if (/\b(?:config|credential|endpoint|workspace|path|env var|environment|docker|sandbox)\b/.test(sample)) {
116
- return 'operations/environment'
117
- }
118
- if (/\b(?:fact|documentation|reference|api|schema)\b/.test(sample)) {
119
- return 'knowledge/facts'
120
- }
88
+ // No content-sniffing regex — the agent picks the category via the guidance
89
+ // in its memory policy block. We just normalize explicit aliases above and
90
+ // fall back to knowledge/facts for uncategorized entries.
121
91
  return explicit && explicit !== 'note' && explicit !== 'notes' ? explicit : 'knowledge/facts'
122
92
  }
123
93
 
@@ -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 {
@@ -404,3 +404,49 @@ describe('buildAgentHeartbeatPrompt', () => {
404
404
  assert.ok(result.includes('Another active task'))
405
405
  })
406
406
  })
407
+
408
+ // ── lightContext config ─────────────────────────────────────────────────
409
+
410
+ describe('heartbeatConfigForSession lightContext', () => {
411
+ it('defaults to false when not set', () => {
412
+ const cfg = mod.heartbeatConfigForSession(
413
+ { id: 's1' },
414
+ { heartbeatIntervalSec: 60 },
415
+ {},
416
+ )
417
+ assert.equal(cfg.lightContext, false)
418
+ })
419
+
420
+ it('inherits from global settings', () => {
421
+ const cfg = mod.heartbeatConfigForSession(
422
+ { id: 's1' },
423
+ { heartbeatIntervalSec: 60, heartbeatLightContext: true },
424
+ {},
425
+ )
426
+ assert.equal(cfg.lightContext, true)
427
+ })
428
+
429
+ it('agent overrides global', () => {
430
+ const agents: Record<string, Record<string, unknown>> = {
431
+ 'a1': { heartbeatLightContext: true },
432
+ }
433
+ const cfg = mod.heartbeatConfigForSession(
434
+ { id: 's1', agentId: 'a1' },
435
+ { heartbeatIntervalSec: 60, heartbeatLightContext: false },
436
+ agents,
437
+ )
438
+ assert.equal(cfg.lightContext, true)
439
+ })
440
+
441
+ it('agent false overrides global true', () => {
442
+ const agents: Record<string, Record<string, unknown>> = {
443
+ 'a1': { heartbeatLightContext: false },
444
+ }
445
+ const cfg = mod.heartbeatConfigForSession(
446
+ { id: 's1', agentId: 'a1' },
447
+ { heartbeatIntervalSec: 60, heartbeatLightContext: true },
448
+ agents,
449
+ )
450
+ assert.equal(cfg.lightContext, false)
451
+ })
452
+ })
@@ -146,6 +146,7 @@ export interface HeartbeatConfig {
146
146
  showOk: boolean
147
147
  showAlerts: boolean
148
148
  target: string | null
149
+ lightContext: boolean
149
150
  }
150
151
 
151
152
  interface HeartbeatFileSession {
@@ -345,6 +346,7 @@ export function heartbeatConfigForSession(session: any, settings: Record<string,
345
346
  let showOk = resolveBool(settings, 'heartbeatShowOk', DEFAULT_HEARTBEAT_SHOW_OK)
346
347
  let showAlerts = resolveBool(settings, 'heartbeatShowAlerts', DEFAULT_HEARTBEAT_SHOW_ALERTS)
347
348
  let target: string | null = resolveStr(settings, 'heartbeatTarget', null)
349
+ let lightContext = resolveBool(settings, 'heartbeatLightContext', false)
348
350
 
349
351
  // Agent layer overrides
350
352
  if (session.agentId) {
@@ -361,6 +363,7 @@ export function heartbeatConfigForSession(session: any, settings: Record<string,
361
363
  showOk = resolveBool(agent, 'heartbeatShowOk', showOk)
362
364
  showAlerts = resolveBool(agent, 'heartbeatShowAlerts', showAlerts)
363
365
  target = resolveStr(agent, 'heartbeatTarget', target)
366
+ lightContext = resolveBool(agent, 'heartbeatLightContext', lightContext)
364
367
  }
365
368
  }
366
369
 
@@ -373,7 +376,7 @@ export function heartbeatConfigForSession(session: any, settings: Record<string,
373
376
  }
374
377
  target = resolveStr(session, 'heartbeatTarget', target)
375
378
 
376
- return { enabled: enabled && intervalSec > 0, intervalSec, prompt, model, ackMaxChars, showOk, showAlerts, target }
379
+ return { enabled: enabled && intervalSec > 0, intervalSec, prompt, model, ackMaxChars, showOk, showAlerts, target, lightContext }
377
380
  }
378
381
 
379
382
  function lastUserMessageAt(session: any): number {
@@ -560,6 +563,7 @@ async function tickHeartbeats() {
560
563
  showOk: cfg.showOk,
561
564
  showAlerts: cfg.showAlerts,
562
565
  target: cfg.target,
566
+ lightContext: cfg.lightContext,
563
567
  },
564
568
  })
565
569
 
@@ -46,13 +46,13 @@ describe('runtime settings defaults', () => {
46
46
  `)
47
47
 
48
48
  assert.equal(output.settings.loopMode, 'bounded')
49
- assert.equal(output.settings.agentLoopRecursionLimit, 120)
49
+ assert.equal(output.settings.agentLoopRecursionLimit, 300)
50
50
  assert.equal(output.settings.orchestratorLoopRecursionLimit, 80)
51
51
  assert.equal(output.settings.legacyOrchestratorMaxTurns, 16)
52
52
  assert.equal(output.settings.ongoingLoopMaxIterations, 250)
53
53
  assert.equal(output.settings.ongoingLoopMaxRuntimeMinutes, 60)
54
54
  assert.equal(output.settings.delegationMaxDepth, 3)
55
- assert.equal(output.settings.shellCommandTimeoutSec, 30)
55
+ assert.equal(output.settings.shellCommandTimeoutSec, 120)
56
56
  assert.equal(output.settings.claudeCodeTimeoutSec, 1800)
57
57
  assert.equal(output.settings.cliProcessTimeoutSec, 1800)
58
58
  assert.equal(output.settings.heartbeatIntervalSec, 1800)
@@ -61,7 +61,7 @@ describe('runtime settings defaults', () => {
61
61
  assert.equal(output.settings.heartbeatShowAlerts, true)
62
62
  assert.equal(output.settings.heartbeatTarget, null)
63
63
  assert.equal(output.settings.heartbeatPrompt, null)
64
- assert.equal(output.runtime.agentLoopRecursionLimit, 120)
64
+ assert.equal(output.runtime.agentLoopRecursionLimit, 300)
65
65
  assert.equal(output.runtime.orchestratorLoopRecursionLimit, 80)
66
66
  assert.equal(output.runtime.legacyOrchestratorMaxTurns, 16)
67
67
  })
@@ -99,7 +99,7 @@ describe('runtime settings defaults', () => {
99
99
  `)
100
100
 
101
101
  assert.equal(output.settings.loopMode, 'bounded')
102
- assert.equal(output.settings.agentLoopRecursionLimit, 200)
102
+ assert.equal(output.settings.agentLoopRecursionLimit, 500)
103
103
  assert.equal(output.settings.orchestratorLoopRecursionLimit, 1)
104
104
  assert.equal(output.settings.legacyOrchestratorMaxTurns, 1)
105
105
  assert.equal(output.settings.ongoingLoopMaxIterations, 5000)
@@ -15,6 +15,8 @@ export interface RuntimeSettings {
15
15
  shellCommandTimeoutMs: number
16
16
  claudeCodeTimeoutMs: number
17
17
  cliProcessTimeoutMs: number
18
+ streamIdleStallMs: number
19
+ requiredToolKickoffMs: number
18
20
  }
19
21
 
20
22
  export function loadRuntimeSettings(): RuntimeSettings {
@@ -32,6 +34,8 @@ export function loadRuntimeSettings(): RuntimeSettings {
32
34
  shellCommandTimeoutMs: normalized.shellCommandTimeoutSec * 1000,
33
35
  claudeCodeTimeoutMs: normalized.claudeCodeTimeoutSec * 1000,
34
36
  cliProcessTimeoutMs: normalized.cliProcessTimeoutSec * 1000,
37
+ streamIdleStallMs: normalized.streamIdleStallSec * 1000,
38
+ requiredToolKickoffMs: normalized.requiredToolKickoffSec * 1000,
35
39
  }
36
40
  }
37
41
 
@@ -52,6 +52,7 @@ interface QueueEntry {
52
52
  showAlerts: boolean
53
53
  target: string | null
54
54
  deliveryMode?: 'default' | 'tool_only'
55
+ lightContext?: boolean
55
56
  }
56
57
  replyToId?: string
57
58
  resolve: (value: ExecuteChatTurnResult) => void
@@ -567,6 +568,7 @@ export interface EnqueueSessionRunInput {
567
568
  showAlerts: boolean
568
569
  target: string | null
569
570
  deliveryMode?: 'default' | 'tool_only'
571
+ lightContext?: boolean
570
572
  }
571
573
  replyToId?: string
572
574
  /** Optional shared execution lane key. When set, multiple sessions can be serialized together. */