@swarmclawai/swarmclaw 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +12 -10
  2. package/bundled-skills/google-workspace/SKILL.md +2 -0
  3. package/package.json +1 -1
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  5. package/src/app/api/clawhub/install/route.ts +2 -0
  6. package/src/app/api/skills/[id]/route.ts +4 -0
  7. package/src/app/api/skills/route.ts +4 -0
  8. package/src/components/agents/agent-sheet.tsx +5 -5
  9. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  10. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  11. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  12. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  13. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  14. package/src/lib/server/agents/orchestrator.ts +11 -7
  15. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  16. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  17. package/src/lib/server/chat-execution/chat-execution.ts +74 -26
  18. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +65 -30
  19. package/src/lib/server/chat-execution/stream-agent-chat.ts +69 -25
  20. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  21. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  22. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  23. package/src/lib/server/connectors/manager.test.ts +504 -73
  24. package/src/lib/server/connectors/manager.ts +40 -9
  25. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  26. package/src/lib/server/connectors/session-kind.ts +7 -0
  27. package/src/lib/server/connectors/session.test.ts +104 -0
  28. package/src/lib/server/connectors/session.ts +5 -2
  29. package/src/lib/server/identity-continuity.test.ts +4 -3
  30. package/src/lib/server/identity-continuity.ts +8 -4
  31. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  32. package/src/lib/server/session-reset-policy.test.ts +17 -3
  33. package/src/lib/server/session-reset-policy.ts +4 -2
  34. package/src/lib/server/session-tools/connector.ts +11 -10
  35. package/src/lib/server/session-tools/crud.ts +41 -7
  36. package/src/lib/server/session-tools/index.ts +2 -0
  37. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  38. package/src/lib/server/session-tools/memory.ts +12 -23
  39. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  40. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  41. package/src/lib/server/session-tools/skills.ts +575 -0
  42. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  43. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  44. package/src/lib/server/skills/skill-discovery.ts +4 -0
  45. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  46. package/src/lib/server/skills/skills-normalize.ts +93 -1
  47. package/src/lib/server/storage.ts +1 -1
  48. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  49. package/src/lib/server/tasks/task-followups.ts +88 -13
  50. package/src/types/index.ts +26 -2
@@ -0,0 +1,194 @@
1
+ import { after, before, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import type { Agent, Skill } from '@/types'
7
+
8
+ const originalEnv = {
9
+ DATA_DIR: process.env.DATA_DIR,
10
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
11
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
12
+ }
13
+
14
+ let tempDir = ''
15
+ let workspaceDir = ''
16
+ let buildCrudTools: Awaited<typeof import('./crud')>['buildCrudTools']
17
+ let loadSkills: Awaited<typeof import('../storage')>['loadSkills']
18
+ let loadAgent: Awaited<typeof import('../storage')>['loadAgent']
19
+ let saveAgents: Awaited<typeof import('../storage')>['saveAgents']
20
+ let saveSessions: Awaited<typeof import('../storage')>['saveSessions']
21
+ let upsertApproval: Awaited<typeof import('../storage')>['upsertApproval']
22
+
23
+ function buildManageSkillsTool() {
24
+ const tools = buildCrudTools({
25
+ cwd: workspaceDir,
26
+ ctx: { sessionId: 'skill-session', agentId: 'agent-skill-test', platformAssignScope: 'self' },
27
+ hasPlugin: (name) => name === 'manage_skills',
28
+ hasTool: (name) => name === 'manage_skills',
29
+ cleanupFns: [],
30
+ commandTimeoutMs: 1_000,
31
+ claudeTimeoutMs: 1_000,
32
+ cliProcessTimeoutMs: 1_000,
33
+ persistDelegateResumeId: () => {},
34
+ readStoredDelegateResumeId: () => null,
35
+ resolveCurrentSession: () => null,
36
+ activePlugins: ['manage_skills', 'google_workspace'],
37
+ })
38
+ const tool = tools.find((entry) => entry.name === 'manage_skills')
39
+ assert.ok(tool, 'expected manage_skills tool')
40
+ return tool!
41
+ }
42
+
43
+ before(async () => {
44
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-manage-skills-'))
45
+ workspaceDir = path.join(tempDir, 'workspace')
46
+ process.env.DATA_DIR = path.join(tempDir, 'data')
47
+ process.env.WORKSPACE_DIR = workspaceDir
48
+ process.env.SWARMCLAW_BUILD_MODE = '1'
49
+ fs.mkdirSync(process.env.DATA_DIR, { recursive: true })
50
+ fs.mkdirSync(workspaceDir, { recursive: true })
51
+
52
+ const crudMod = await import('./crud')
53
+ buildCrudTools = crudMod.buildCrudTools
54
+
55
+ const storageMod = await import('../storage')
56
+ loadSkills = storageMod.loadSkills
57
+ loadAgent = storageMod.loadAgent
58
+ saveAgents = storageMod.saveAgents
59
+ saveSessions = storageMod.saveSessions
60
+ upsertApproval = storageMod.upsertApproval
61
+
62
+ saveAgents({
63
+ 'agent-skill-test': {
64
+ id: 'agent-skill-test',
65
+ name: 'Skill Tester',
66
+ provider: 'openai',
67
+ model: 'gpt-test',
68
+ plugins: ['manage_skills'],
69
+ tools: ['manage_skills'],
70
+ skillIds: [],
71
+ platformAssignScope: 'self',
72
+ createdAt: Date.now(),
73
+ updatedAt: Date.now(),
74
+ } satisfies Agent,
75
+ })
76
+ saveSessions({
77
+ 'skill-session': {
78
+ id: 'skill-session',
79
+ name: 'Skill Session',
80
+ cwd: workspaceDir,
81
+ user: 'tester',
82
+ provider: 'openai',
83
+ model: 'gpt-test',
84
+ messages: [],
85
+ createdAt: Date.now(),
86
+ lastActiveAt: Date.now(),
87
+ sessionType: 'human',
88
+ agentId: 'agent-skill-test',
89
+ plugins: ['manage_skills'],
90
+ heartbeatEnabled: false,
91
+ },
92
+ })
93
+ })
94
+
95
+ after(() => {
96
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
97
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
98
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
99
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
100
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
101
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
102
+ fs.rmSync(tempDir, { recursive: true, force: true })
103
+ })
104
+
105
+ describe('manage_skills runtime actions', () => {
106
+ it('status reports resolved local skills with eligibility and metadata', async () => {
107
+ const manageSkills = buildManageSkillsTool()
108
+ const created = await manageSkills.invoke({
109
+ action: 'create',
110
+ name: 'workspace-helper',
111
+ description: 'Automate workspace docs.',
112
+ content: '# Workspace Helper\nUse the workspace workflow.',
113
+ toolNames: ['google_workspace'],
114
+ capabilities: ['docs', 'workspace'],
115
+ })
116
+ const createdSkill = JSON.parse(String(created)) as Skill
117
+
118
+ const raw = await manageSkills.invoke({ action: 'status', query: 'workspace docs' })
119
+ const result = JSON.parse(String(raw)) as Array<Record<string, unknown>>
120
+
121
+ const statusEntry = result.find((entry) => entry.storageId === createdSkill.id)
122
+ assert.ok(statusEntry)
123
+ assert.equal(statusEntry?.eligible, true)
124
+ assert.deepEqual(statusEntry?.toolNames, ['google_workspace'])
125
+ })
126
+
127
+ it('attach materializes a discovered project skill and binds it to the current agent', async () => {
128
+ const localSkillDir = path.join(workspaceDir, 'skills', 'project-helper')
129
+ fs.mkdirSync(localSkillDir, { recursive: true })
130
+ fs.writeFileSync(path.join(localSkillDir, 'SKILL.md'), `---
131
+ name: project-helper
132
+ description: Project-local helper.
133
+ metadata:
134
+ openclaw:
135
+ toolNames: [google_workspace]
136
+ ---
137
+ # Project Helper
138
+
139
+ Use the project helper.
140
+ `)
141
+
142
+ const manageSkills = buildManageSkillsTool()
143
+ const raw = await manageSkills.invoke({
144
+ action: 'attach',
145
+ name: 'project-helper',
146
+ })
147
+ const result = JSON.parse(String(raw)) as Record<string, unknown>
148
+ const skillId = String(result.skillId || '')
149
+ const agent = loadAgent('agent-skill-test') as Agent
150
+
151
+ assert.ok(skillId)
152
+ assert.ok(loadSkills()[skillId], 'discovered skill copied into managed storage')
153
+ assert.ok(agent.skillIds?.includes(skillId), 'skill attached to current agent')
154
+ })
155
+
156
+ it('install is approval-gated and can install a remote skill after approval', async () => {
157
+ const manageSkills = buildManageSkillsTool()
158
+ const firstRaw = await manageSkills.invoke({
159
+ action: 'install',
160
+ name: 'remote-helper',
161
+ url: 'https://clawhub.ai/skills/remote-helper',
162
+ content: '# Remote Helper\nUse the remote helper.',
163
+ attach: true,
164
+ })
165
+ const first = JSON.parse(String(firstRaw)) as Record<string, unknown>
166
+ const approval = first.approval as { id: string }
167
+
168
+ assert.equal(first.requiresApproval, true)
169
+ assert.ok(approval?.id)
170
+
171
+ upsertApproval(approval.id, {
172
+ ...(first.approval as Record<string, unknown>),
173
+ status: 'approved',
174
+ updatedAt: Date.now(),
175
+ })
176
+
177
+ const secondRaw = await manageSkills.invoke({
178
+ action: 'install',
179
+ name: 'remote-helper',
180
+ url: 'https://clawhub.ai/skills/remote-helper',
181
+ content: '# Remote Helper\nUse the remote helper.',
182
+ attach: true,
183
+ approvalId: approval.id,
184
+ })
185
+ const second = JSON.parse(String(secondRaw)) as Record<string, unknown>
186
+ const installedSkill = second.skill as Skill
187
+ const agent = loadAgent('agent-skill-test') as Agent
188
+
189
+ assert.equal(second.ok, true)
190
+ assert.ok(installedSkill?.id)
191
+ assert.ok(loadSkills()[installedSkill.id])
192
+ assert.ok(agent.skillIds?.includes(installedSkill.id), 'approved install can attach to the agent')
193
+ })
194
+ })
@@ -24,43 +24,32 @@ import {
24
24
  shouldAutoCaptureMemoryTurn,
25
25
  shouldInjectMemoryContext,
26
26
  } from '@/lib/server/memory/memory-policy'
27
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
27
28
 
28
29
  /**
29
30
  * Advanced Database-Backed Memory logic.
30
31
  */
31
32
 
32
- /**
33
- * Lightweight in-memory cache for per-agent memory lookups (pinned + recent).
34
- * TTL-based with invalidation on any write operation.
35
- */
36
- const MEMORY_CACHE_TTL_MS = 30_000
37
- interface AgentMemoryCache {
33
+ type DisabledAgentMemoryCacheEntry = {
38
34
  pinned: MemoryEntry[]
39
35
  allRecent: MemoryEntry[]
40
- cachedAt: number
41
36
  }
42
- const agentMemoryCache = new Map<string, AgentMemoryCache>()
43
37
 
44
- function getCachedAgentMemories(agentId: string): AgentMemoryCache | null {
45
- const cached = agentMemoryCache.get(agentId)
46
- if (!cached) return null
47
- if (Date.now() - cached.cachedAt > MEMORY_CACHE_TTL_MS) {
48
- agentMemoryCache.delete(agentId)
49
- return null
50
- }
51
- return cached
38
+ function getCachedAgentMemories(agentId: string): DisabledAgentMemoryCacheEntry | null {
39
+ void agentId
40
+ return null
52
41
  }
53
42
 
54
43
  function setCachedAgentMemories(agentId: string, pinned: MemoryEntry[], allRecent: MemoryEntry[]): void {
55
- agentMemoryCache.set(agentId, { pinned, allRecent, cachedAt: Date.now() })
44
+ void agentId
45
+ void pinned
46
+ void allRecent
47
+ // Intentionally disabled until we can prove memory reads stay complete and fresh.
56
48
  }
57
49
 
58
50
  function invalidateAgentMemoryCache(agentId?: string | null): void {
59
- if (agentId) {
60
- agentMemoryCache.delete(agentId)
61
- } else {
62
- agentMemoryCache.clear()
63
- }
51
+ void agentId
52
+ // Intentionally disabled: the per-agent in-memory cache is not used.
64
53
  }
65
54
  type MemoryActionContext = Partial<Session> & {
66
55
  sessionId?: string | null
@@ -660,7 +649,7 @@ const MemoryPlugin: Plugin = {
660
649
 
661
650
  // QMD scope: identity/* memories and contact resolution are private (DM/peer only).
662
651
  // Group channels, threads, and shared "main" sessions don't see them.
663
- const connCtx = ctx.session.connectorContext
652
+ const connCtx = isDirectConnectorSession(ctx.session) ? ctx.session.connectorContext : null
664
653
  const isPrivateContext = !connCtx || !connCtx.isGroup
665
654
 
666
655
  const memDb = getMemoryDb()
@@ -0,0 +1,175 @@
1
+ import { after, before, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import type { Agent, Skill } from '@/types'
7
+
8
+ const originalEnv = {
9
+ DATA_DIR: process.env.DATA_DIR,
10
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
11
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
12
+ }
13
+
14
+ let tempDir = ''
15
+ let workspaceDir = ''
16
+ let buildSessionTools: Awaited<typeof import('./index')>['buildSessionTools']
17
+ let saveAgents: Awaited<typeof import('../storage')>['saveAgents']
18
+ let saveSessions: Awaited<typeof import('../storage')>['saveSessions']
19
+ let saveSkills: Awaited<typeof import('../storage')>['saveSkills']
20
+ let loadSession: Awaited<typeof import('../storage')>['loadSession']
21
+
22
+ async function buildUseSkillTool() {
23
+ const built = await buildSessionTools(workspaceDir, ['manage_skills'], {
24
+ sessionId: 'skill-runtime-session',
25
+ agentId: 'skill-runtime-agent',
26
+ platformAssignScope: 'self',
27
+ })
28
+ const tool = built.tools.find((entry) => entry.name === 'use_skill')
29
+ assert.ok(tool, 'expected use_skill tool')
30
+ return { built, tool: tool! }
31
+ }
32
+
33
+ before(async () => {
34
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-skill-runtime-'))
35
+ workspaceDir = path.join(tempDir, 'workspace')
36
+ process.env.DATA_DIR = path.join(tempDir, 'data')
37
+ process.env.WORKSPACE_DIR = workspaceDir
38
+ process.env.SWARMCLAW_BUILD_MODE = '1'
39
+ fs.mkdirSync(process.env.DATA_DIR, { recursive: true })
40
+ fs.mkdirSync(workspaceDir, { recursive: true })
41
+
42
+ const toolsMod = await import('./index')
43
+ buildSessionTools = toolsMod.buildSessionTools
44
+
45
+ const storageMod = await import('../storage')
46
+ saveAgents = storageMod.saveAgents
47
+ saveSessions = storageMod.saveSessions
48
+ saveSkills = storageMod.saveSkills
49
+ loadSession = storageMod.loadSession
50
+
51
+ saveAgents({
52
+ 'skill-runtime-agent': {
53
+ id: 'skill-runtime-agent',
54
+ name: 'Skill Runtime Tester',
55
+ description: 'Tests runtime skill execution.',
56
+ provider: 'openai',
57
+ model: 'gpt-test',
58
+ plugins: ['manage_skills'],
59
+ tools: ['manage_skills'],
60
+ skillIds: [],
61
+ platformAssignScope: 'self',
62
+ createdAt: Date.now(),
63
+ updatedAt: Date.now(),
64
+ } satisfies Agent,
65
+ })
66
+
67
+ saveSessions({
68
+ 'skill-runtime-session': {
69
+ id: 'skill-runtime-session',
70
+ name: 'Skill Runtime Session',
71
+ cwd: workspaceDir,
72
+ user: 'tester',
73
+ provider: 'openai',
74
+ model: 'gpt-test',
75
+ messages: [],
76
+ createdAt: Date.now(),
77
+ lastActiveAt: Date.now(),
78
+ sessionType: 'human',
79
+ agentId: 'skill-runtime-agent',
80
+ plugins: ['manage_skills'],
81
+ heartbeatEnabled: false,
82
+ },
83
+ })
84
+
85
+ saveSkills({
86
+ dispatch_skill: {
87
+ id: 'dispatch_skill',
88
+ name: 'dispatch-helper',
89
+ filename: 'dispatch-helper.md',
90
+ description: 'Dispatch through manage_skills status.',
91
+ content: '# Dispatch Helper\nRun manage_skills status.',
92
+ commandDispatch: {
93
+ kind: 'tool',
94
+ toolName: 'manage_skills',
95
+ argMode: 'raw',
96
+ },
97
+ createdAt: Date.now(),
98
+ updatedAt: Date.now(),
99
+ } satisfies Skill,
100
+ prompt_skill: {
101
+ id: 'prompt_skill',
102
+ name: 'prompt-helper',
103
+ filename: 'prompt-helper.md',
104
+ description: 'Guidance-only workflow.',
105
+ content: '# Prompt Helper\nFollow this checklist.',
106
+ createdAt: Date.now(),
107
+ updatedAt: Date.now(),
108
+ } satisfies Skill,
109
+ })
110
+ })
111
+
112
+ after(() => {
113
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
114
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
115
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
116
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
117
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
118
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
119
+ fs.rmSync(tempDir, { recursive: true, force: true })
120
+ })
121
+
122
+ describe('use_skill runtime tool', () => {
123
+ it('selects a skill and persists the selection on the session', async () => {
124
+ const { built, tool } = await buildUseSkillTool()
125
+ try {
126
+ const raw = await tool.invoke({ action: 'select', name: 'dispatch-helper' })
127
+ const result = JSON.parse(String(raw)) as Record<string, unknown>
128
+ const session = loadSession('skill-runtime-session')
129
+
130
+ assert.equal(result.ok, true)
131
+ assert.equal((result.skill as Record<string, unknown>)?.name, 'dispatch-helper')
132
+ assert.equal(session?.skillRuntimeState?.selectedSkillName, 'dispatch-helper')
133
+ } finally {
134
+ await built.cleanup()
135
+ }
136
+ })
137
+
138
+ it('runs an executable skill by dispatching into its bound tool', async () => {
139
+ const { built, tool } = await buildUseSkillTool()
140
+ try {
141
+ const raw = await tool.invoke({
142
+ action: 'run',
143
+ name: 'dispatch-helper',
144
+ toolArgs: { action: 'status', query: 'dispatch helper' },
145
+ })
146
+ const result = JSON.parse(String(raw)) as Record<string, unknown>
147
+ const toolOutput = result.toolOutput as Array<Record<string, unknown>>
148
+ const session = loadSession('skill-runtime-session')
149
+
150
+ assert.equal(result.ok, true)
151
+ assert.equal(result.executed, true)
152
+ assert.equal(result.dispatchedTool, 'manage_skills')
153
+ assert.ok(Array.isArray(toolOutput))
154
+ assert.equal(session?.skillRuntimeState?.lastAction, 'run')
155
+ assert.equal(session?.skillRuntimeState?.lastRunToolName, 'manage_skills')
156
+ } finally {
157
+ await built.cleanup()
158
+ }
159
+ })
160
+
161
+ it('falls back to prompt guidance for non-executable skills', async () => {
162
+ const { built, tool } = await buildUseSkillTool()
163
+ try {
164
+ const raw = await tool.invoke({ action: 'run', name: 'prompt-helper' })
165
+ const result = JSON.parse(String(raw)) as Record<string, unknown>
166
+
167
+ assert.equal(result.ok, true)
168
+ assert.equal(result.executed, false)
169
+ assert.equal(result.mode, 'prompt_guidance')
170
+ assert.match(String(result.guidance || ''), /Prompt Helper/)
171
+ } finally {
172
+ await built.cleanup()
173
+ }
174
+ })
175
+ })