@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.
- package/README.md +12 -10
- package/bundled-skills/google-workspace/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/app/agents/page.tsx +2 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
- package/src/app/api/clawhub/install/route.ts +2 -0
- package/src/app/api/skills/[id]/route.ts +4 -0
- package/src/app/api/skills/route.ts +4 -0
- package/src/app/globals.css +28 -0
- package/src/app/home/page.tsx +11 -0
- package/src/app/settings/page.tsx +12 -5
- package/src/components/agents/agent-sheet.tsx +5 -5
- package/src/components/connectors/connector-list.tsx +2 -5
- package/src/components/logs/log-list.tsx +2 -5
- package/src/components/providers/provider-list.tsx +2 -5
- package/src/components/runs/run-list.tsx +2 -6
- package/src/components/schedules/schedule-list.tsx +7 -1
- package/src/components/ui/full-screen-loader.tsx +0 -29
- package/src/components/ui/page-loader.tsx +69 -0
- package/src/lib/runtime/runtime-loop.ts +21 -1
- package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
- package/src/lib/server/agents/agent-thread-session.ts +1 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
- package/src/lib/server/agents/main-agent-loop.ts +259 -0
- package/src/lib/server/agents/orchestrator-lg.ts +12 -8
- package/src/lib/server/agents/orchestrator.ts +11 -7
- package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
- package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
- package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
- package/src/lib/server/chat-execution/chat-execution.ts +116 -29
- package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +67 -76
- package/src/lib/server/chat-execution/stream-agent-chat.ts +119 -110
- package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
- package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
- package/src/lib/server/connectors/contact-boundaries.ts +101 -0
- package/src/lib/server/connectors/manager.test.ts +504 -73
- package/src/lib/server/connectors/manager.ts +41 -10
- package/src/lib/server/connectors/session-consolidation.ts +2 -0
- package/src/lib/server/connectors/session-kind.ts +7 -0
- package/src/lib/server/connectors/session.test.ts +104 -0
- package/src/lib/server/connectors/session.ts +5 -2
- package/src/lib/server/identity-continuity.test.ts +4 -3
- package/src/lib/server/identity-continuity.ts +8 -4
- package/src/lib/server/memory/memory-policy.test.ts +5 -15
- package/src/lib/server/memory/memory-policy.ts +11 -41
- package/src/lib/server/memory/session-archive-memory.ts +2 -1
- package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
- package/src/lib/server/runtime/heartbeat-service.ts +5 -1
- package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
- package/src/lib/server/runtime/runtime-settings.ts +4 -0
- package/src/lib/server/runtime/session-run-manager.ts +2 -0
- package/src/lib/server/session-reset-policy.test.ts +17 -3
- package/src/lib/server/session-reset-policy.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +11 -10
- package/src/lib/server/session-tools/crud.ts +41 -7
- package/src/lib/server/session-tools/delegate.ts +3 -3
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
- package/src/lib/server/session-tools/memory.ts +209 -48
- package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
- package/src/lib/server/session-tools/skill-runtime.ts +382 -0
- package/src/lib/server/session-tools/skills.ts +575 -0
- package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
- package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
- package/src/lib/server/skills/skill-discovery.ts +4 -0
- package/src/lib/server/skills/skills-normalize.test.ts +28 -0
- package/src/lib/server/skills/skills-normalize.ts +93 -1
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-followups.test.ts +124 -0
- package/src/lib/server/tasks/task-followups.ts +88 -13
- package/src/types/index.ts +30 -2
- 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
|
-
|
|
1524
|
-
const
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
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]
|
|
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: '
|
|
35
|
+
name: 'connector:checkout-bug',
|
|
36
36
|
cwd: process.cwd(),
|
|
37
|
-
user: '
|
|
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: '
|
|
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
|
|
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(
|
|
56
|
+
const threadTitle = normalizeText(connectorContext?.threadTitle, 120)
|
|
55
57
|
if (threadTitle) return threadTitle
|
|
56
|
-
const threadId = normalizeText(
|
|
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 =
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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. */
|