@swarmclawai/swarmclaw 1.4.7 → 1.4.8

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 (33) hide show
  1. package/README.md +13 -2
  2. package/package.json +1 -1
  3. package/src/app/api/swarmfeed/actions/route.ts +101 -0
  4. package/src/app/api/swarmfeed/bookmarks/route.ts +30 -0
  5. package/src/app/api/swarmfeed/notifications/route.ts +30 -0
  6. package/src/app/api/swarmfeed/posts/[postId]/replies/route.ts +23 -0
  7. package/src/app/api/swarmfeed/posts/[postId]/route.ts +20 -0
  8. package/src/app/api/swarmfeed/posts/route.ts +12 -52
  9. package/src/app/api/swarmfeed/profiles/[agentId]/posts/route.ts +25 -0
  10. package/src/app/api/swarmfeed/profiles/[agentId]/route.ts +32 -0
  11. package/src/app/api/swarmfeed/route.ts +15 -13
  12. package/src/app/api/swarmfeed/search/route.ts +30 -0
  13. package/src/app/api/swarmfeed/suggested/route.ts +25 -0
  14. package/src/cli/index.js +11 -0
  15. package/src/features/swarmfeed/agent-social-settings.tsx +7 -1
  16. package/src/features/swarmfeed/compose-post.tsx +72 -87
  17. package/src/features/swarmfeed/feed-page.tsx +607 -76
  18. package/src/features/swarmfeed/post-card.tsx +205 -73
  19. package/src/features/swarmfeed/post-thread-sheet.tsx +155 -0
  20. package/src/features/swarmfeed/profile-sheet.tsx +179 -0
  21. package/src/features/swarmfeed/queries.ts +191 -8
  22. package/src/lib/server/agents/agent-swarm-registration.ts +31 -8
  23. package/src/lib/server/runtime/heartbeat-service.ts +8 -1
  24. package/src/lib/server/runtime/queue/core.ts +2 -0
  25. package/src/lib/server/session-tools/swarmfeed.ts +226 -63
  26. package/src/lib/server/storage-normalization.ts +1 -0
  27. package/src/lib/server/swarmfeed-runtime.test.ts +188 -0
  28. package/src/lib/server/swarmfeed-runtime.ts +131 -0
  29. package/src/lib/server/tasks/task-route-service.ts +2 -0
  30. package/src/lib/swarmfeed-client.ts +130 -28
  31. package/src/lib/tool-definitions.ts +1 -1
  32. package/src/types/agent.ts +1 -0
  33. package/src/types/swarmfeed.ts +105 -5
@@ -1,7 +1,27 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
- import { getAgent, patchAgent } from '@/lib/server/agents/agent-repository'
4
- import { createPost, getFeed, likePost, repostPost, getChannels, registerAgent } from '@/lib/swarmfeed-client'
3
+ import { getAgent } from '@/lib/server/agents/agent-repository'
4
+ import { ensureSwarmFeedAgent } from '@/lib/server/agents/agent-swarm-registration'
5
+ import {
6
+ bookmarkPost,
7
+ createPost,
8
+ followAgent,
9
+ getChannels,
10
+ getFeed,
11
+ getNotifications,
12
+ getPost,
13
+ getPostReplies,
14
+ getProfile,
15
+ getSuggestedFollows,
16
+ likePost,
17
+ repostPost,
18
+ searchSwarmFeed,
19
+ unbookmarkPost,
20
+ unfollowAgent,
21
+ unlikePost,
22
+ unrepostPost,
23
+ } from '@/lib/swarmfeed-client'
24
+ import { canAutoPostToSwarmFeed, markSwarmFeedAutoPost } from '@/lib/server/swarmfeed-runtime'
5
25
  import { log } from '@/lib/server/logger'
6
26
  import type { ToolBuildContext } from './context'
7
27
  import type { Agent } from '@/types'
@@ -9,111 +29,256 @@ import type { Agent } from '@/types'
9
29
  const TAG = 'swarmfeed-tool'
10
30
 
11
31
  const SWARMFEED_SCHEMA = z.object({
12
- action: z.enum(['post', 'reply', 'like', 'repost', 'browse_feed', 'get_channels']).describe(
13
- 'The SwarmFeed action to perform',
14
- ),
15
- content: z.string().optional().describe('Post content (required for post/reply)'),
16
- postId: z.string().optional().describe('Post ID (required for reply/like/repost)'),
17
- channelId: z.string().optional().describe('Channel ID for posting to a channel or browsing a channel feed'),
18
- feedType: z.enum(['for_you', 'following', 'trending', 'channel']).optional().describe('Feed type for browse_feed (default: for_you)'),
19
- limit: z.number().optional().describe('Number of posts to fetch for browse_feed (default: 10)'),
32
+ action: z.enum([
33
+ 'post',
34
+ 'reply',
35
+ 'quote_repost',
36
+ 'like',
37
+ 'unlike',
38
+ 'repost',
39
+ 'unrepost',
40
+ 'bookmark',
41
+ 'unbookmark',
42
+ 'follow',
43
+ 'unfollow',
44
+ 'browse_feed',
45
+ 'search',
46
+ 'get_channels',
47
+ 'get_post_thread',
48
+ 'get_notifications',
49
+ 'get_profile',
50
+ 'get_suggested_follows',
51
+ ]).describe('The SwarmFeed action to perform'),
52
+ content: z.string().optional().describe('Post or reply content'),
53
+ postId: z.string().optional().describe('Post ID for thread/reaction/reply actions'),
54
+ channelId: z.string().optional().describe('Channel ID for posting or browsing a channel feed'),
55
+ feedType: z.enum(['for_you', 'following', 'trending', 'channel']).optional().describe('Feed type for browse_feed'),
56
+ limit: z.number().optional().describe('Number of items to fetch'),
57
+ targetAgentId: z.string().optional().describe('Remote SwarmFeed agent ID for follow/unfollow/get_profile'),
58
+ query: z.string().optional().describe('Search query for search action'),
59
+ searchType: z.enum(['posts', 'agents', 'channels', 'hashtags']).optional().describe('Filter for search action'),
20
60
  })
21
61
 
22
62
  type SwarmFeedInput = z.infer<typeof SWARMFEED_SCHEMA>
23
63
 
24
- async function ensureApiKey(agent: Agent): Promise<string> {
25
- if (agent.swarmfeedApiKey) return agent.swarmfeedApiKey
26
-
27
- log.info(TAG, `Auto-registering agent "${agent.name}" on SwarmFeed`)
28
- const reg = await registerAgent({
29
- name: agent.name,
30
- description: agent.description || agent.swarmfeedBio || `${agent.name} agent on SwarmClaw`,
31
- framework: 'swarmclaw',
32
- model: agent.model,
33
- avatar: agent.avatarUrl || undefined,
34
- bio: agent.swarmfeedBio || undefined,
35
- })
36
-
37
- patchAgent(agent.id, (current) => {
38
- if (!current) return null
39
- return {
40
- ...current,
41
- swarmfeedApiKey: reg.apiKey,
42
- swarmfeedAgentId: reg.agentId,
43
- swarmfeedJoinedAt: current.swarmfeedJoinedAt ?? Date.now(),
44
- updatedAt: Date.now(),
45
- }
46
- })
64
+ function isAutonomousSocialPostSession(bctx: ToolBuildContext): boolean {
65
+ const session = bctx.resolveCurrentSession()
66
+ return Boolean(session?.heartbeatEnabled)
67
+ }
47
68
 
48
- return reg.apiKey
69
+ async function getScopedAgent(agentId: string): Promise<Agent> {
70
+ return ensureSwarmFeedAgent(agentId)
71
+ }
72
+
73
+ function summarizePosts(posts: Array<{
74
+ id: string
75
+ agent?: { name?: string | null }
76
+ agentId: string
77
+ content: string
78
+ likeCount: number
79
+ replyCount: number
80
+ repostCount: number
81
+ createdAt: string
82
+ }>): Array<Record<string, unknown>> {
83
+ return posts.map((post) => ({
84
+ id: post.id,
85
+ agentId: post.agentId,
86
+ agentName: post.agent?.name || null,
87
+ content: post.content.slice(0, 500),
88
+ likes: post.likeCount,
89
+ replies: post.replyCount,
90
+ reposts: post.repostCount,
91
+ createdAt: post.createdAt,
92
+ }))
49
93
  }
50
94
 
51
95
  async function executeSwarmFeed(input: SwarmFeedInput, bctx: ToolBuildContext): Promise<string> {
52
96
  const agentId = bctx.ctx?.agentId
53
97
  if (!agentId) return JSON.stringify({ error: 'No agent context' })
54
98
 
55
- const agent = getAgent(agentId) as Agent | undefined
56
- if (!agent) return JSON.stringify({ error: 'Agent not found' })
57
- if (!agent.swarmfeedEnabled) return JSON.stringify({ error: 'SwarmFeed is not enabled for this agent' })
99
+ const storedAgent = getAgent(agentId) as Agent | undefined
100
+ if (!storedAgent) return JSON.stringify({ error: 'Agent not found' })
101
+ if (!storedAgent.swarmfeedEnabled) return JSON.stringify({ error: 'SwarmFeed is not enabled for this agent' })
102
+
103
+ const autoPostPolicy = canAutoPostToSwarmFeed(storedAgent)
104
+ const autonomousSession = isAutonomousSocialPostSession(bctx)
58
105
 
59
106
  try {
60
107
  switch (input.action) {
61
108
  case 'post': {
62
109
  if (!input.content?.trim()) return JSON.stringify({ error: 'content is required for post action' })
63
- const apiKey = await ensureApiKey(agent)
64
- const post = await createPost(apiKey, {
110
+ if (autonomousSession && !autoPostPolicy.allowed) return JSON.stringify({ error: autoPostPolicy.reason })
111
+ const agent = await getScopedAgent(agentId)
112
+ const post = await createPost(agent.swarmfeedApiKey!, {
65
113
  content: input.content.trim(),
66
114
  channelId: input.channelId,
67
115
  })
116
+ if (autonomousSession) markSwarmFeedAutoPost(agentId)
68
117
  return JSON.stringify({ success: true, post: { id: post.id, content: post.content, createdAt: post.createdAt } })
69
118
  }
70
119
 
71
120
  case 'reply': {
72
121
  if (!input.content?.trim()) return JSON.stringify({ error: 'content is required for reply action' })
73
122
  if (!input.postId) return JSON.stringify({ error: 'postId is required for reply action' })
74
- const apiKey = await ensureApiKey(agent)
75
- const reply = await createPost(apiKey, {
123
+ if (autonomousSession && !autoPostPolicy.allowed) return JSON.stringify({ error: autoPostPolicy.reason })
124
+ const agent = await getScopedAgent(agentId)
125
+ const reply = await createPost(agent.swarmfeedApiKey!, {
76
126
  content: input.content.trim(),
77
127
  parentId: input.postId,
78
128
  channelId: input.channelId,
79
129
  })
80
- return JSON.stringify({ success: true, post: { id: reply.id, content: reply.content, parentId: input.postId, createdAt: reply.createdAt } })
130
+ if (autonomousSession) markSwarmFeedAutoPost(agentId)
131
+ return JSON.stringify({
132
+ success: true,
133
+ post: { id: reply.id, content: reply.content, parentId: input.postId, createdAt: reply.createdAt },
134
+ })
135
+ }
136
+
137
+ case 'quote_repost': {
138
+ if (!input.content?.trim()) return JSON.stringify({ error: 'content is required for quote_repost action' })
139
+ if (!input.postId) return JSON.stringify({ error: 'postId is required for quote_repost action' })
140
+ if (autonomousSession && !autoPostPolicy.allowed) return JSON.stringify({ error: autoPostPolicy.reason })
141
+ const agent = await getScopedAgent(agentId)
142
+ const post = await createPost(agent.swarmfeedApiKey!, {
143
+ content: input.content.trim(),
144
+ channelId: input.channelId,
145
+ quotedPostId: input.postId,
146
+ })
147
+ if (autonomousSession) markSwarmFeedAutoPost(agentId)
148
+ return JSON.stringify({ success: true, post: { id: post.id, content: post.content, quotedPostId: input.postId, createdAt: post.createdAt } })
81
149
  }
82
150
 
83
151
  case 'like': {
84
152
  if (!input.postId) return JSON.stringify({ error: 'postId is required for like action' })
85
- const apiKey = await ensureApiKey(agent)
86
- await likePost(apiKey, input.postId)
153
+ const agent = await getScopedAgent(agentId)
154
+ await likePost(agent.swarmfeedApiKey!, input.postId)
87
155
  return JSON.stringify({ success: true, action: 'liked', postId: input.postId })
88
156
  }
89
157
 
158
+ case 'unlike': {
159
+ if (!input.postId) return JSON.stringify({ error: 'postId is required for unlike action' })
160
+ const agent = await getScopedAgent(agentId)
161
+ await unlikePost(agent.swarmfeedApiKey!, input.postId)
162
+ return JSON.stringify({ success: true, action: 'unliked', postId: input.postId })
163
+ }
164
+
90
165
  case 'repost': {
91
166
  if (!input.postId) return JSON.stringify({ error: 'postId is required for repost action' })
92
- const apiKey = await ensureApiKey(agent)
93
- await repostPost(apiKey, input.postId)
167
+ const agent = await getScopedAgent(agentId)
168
+ await repostPost(agent.swarmfeedApiKey!, input.postId)
94
169
  return JSON.stringify({ success: true, action: 'reposted', postId: input.postId })
95
170
  }
96
171
 
172
+ case 'unrepost': {
173
+ if (!input.postId) return JSON.stringify({ error: 'postId is required for unrepost action' })
174
+ const agent = await getScopedAgent(agentId)
175
+ await unrepostPost(agent.swarmfeedApiKey!, input.postId)
176
+ return JSON.stringify({ success: true, action: 'unreposted', postId: input.postId })
177
+ }
178
+
179
+ case 'bookmark': {
180
+ if (!input.postId) return JSON.stringify({ error: 'postId is required for bookmark action' })
181
+ const agent = await getScopedAgent(agentId)
182
+ await bookmarkPost(agent.swarmfeedApiKey!, input.postId)
183
+ return JSON.stringify({ success: true, action: 'bookmarked', postId: input.postId })
184
+ }
185
+
186
+ case 'unbookmark': {
187
+ if (!input.postId) return JSON.stringify({ error: 'postId is required for unbookmark action' })
188
+ const agent = await getScopedAgent(agentId)
189
+ await unbookmarkPost(agent.swarmfeedApiKey!, input.postId)
190
+ return JSON.stringify({ success: true, action: 'unbookmarked', postId: input.postId })
191
+ }
192
+
193
+ case 'follow': {
194
+ if (!input.targetAgentId) return JSON.stringify({ error: 'targetAgentId is required for follow action' })
195
+ const agent = await getScopedAgent(agentId)
196
+ await followAgent(agent.swarmfeedApiKey!, input.targetAgentId)
197
+ return JSON.stringify({ success: true, action: 'followed', targetAgentId: input.targetAgentId })
198
+ }
199
+
200
+ case 'unfollow': {
201
+ if (!input.targetAgentId) return JSON.stringify({ error: 'targetAgentId is required for unfollow action' })
202
+ const agent = await getScopedAgent(agentId)
203
+ await unfollowAgent(agent.swarmfeedApiKey!, input.targetAgentId)
204
+ return JSON.stringify({ success: true, action: 'unfollowed', targetAgentId: input.targetAgentId })
205
+ }
206
+
97
207
  case 'browse_feed': {
98
208
  const feedType = input.feedType || 'for_you'
99
209
  const limit = input.limit || 10
100
- const apiKey = agent.swarmfeedApiKey || undefined
101
- const result = await getFeed(feedType, { channelId: input.channelId, limit }, apiKey)
102
- const posts = result.posts.map((p) => ({
103
- id: p.id,
104
- agent: p.agentId,
105
- content: p.content.slice(0, 500),
106
- likes: p.likeCount,
107
- replies: p.replyCount,
108
- reposts: p.repostCount,
109
- createdAt: p.createdAt,
110
- }))
111
- return JSON.stringify({ posts, nextCursor: result.nextCursor })
210
+ const agent = feedType === 'following' || autonomousSession ? await getScopedAgent(agentId) : storedAgent
211
+ const result = await getFeed(feedType, { channelId: input.channelId, limit }, agent.swarmfeedApiKey || undefined)
212
+ return JSON.stringify({ posts: summarizePosts(result.posts), nextCursor: result.nextCursor })
213
+ }
214
+
215
+ case 'search': {
216
+ if (!input.query?.trim()) return JSON.stringify({ error: 'query is required for search action' })
217
+ const result = await searchSwarmFeed({
218
+ query: input.query.trim(),
219
+ type: input.searchType,
220
+ limit: input.limit || 10,
221
+ })
222
+ return JSON.stringify({
223
+ total: result.total,
224
+ posts: summarizePosts(result.posts || []),
225
+ agents: (result.agents || []).map((agent) => ({
226
+ id: agent.id,
227
+ name: agent.name,
228
+ framework: agent.framework || null,
229
+ followerCount: agent.followerCount,
230
+ bio: agent.bio || null,
231
+ })),
232
+ channels: (result.channels || []).map((channel) => ({
233
+ id: channel.id,
234
+ handle: channel.handle,
235
+ displayName: channel.displayName,
236
+ memberCount: channel.memberCount,
237
+ })),
238
+ hashtags: result.hashtags || [],
239
+ })
112
240
  }
113
241
 
114
242
  case 'get_channels': {
115
243
  const channels = await getChannels()
116
- return JSON.stringify({ channels: channels.map((c) => ({ id: c.id, name: c.displayName, handle: c.handle })) })
244
+ return JSON.stringify({
245
+ channels: channels.map((channel) => ({
246
+ id: channel.id,
247
+ name: channel.displayName,
248
+ handle: channel.handle,
249
+ description: channel.description || null,
250
+ memberCount: channel.memberCount,
251
+ })),
252
+ })
253
+ }
254
+
255
+ case 'get_post_thread': {
256
+ if (!input.postId) return JSON.stringify({ error: 'postId is required for get_post_thread action' })
257
+ const [post, replies] = await Promise.all([
258
+ getPost(input.postId),
259
+ getPostReplies(input.postId, { limit: input.limit || 20 }),
260
+ ])
261
+ return JSON.stringify({
262
+ post,
263
+ replies: summarizePosts(replies.posts),
264
+ nextCursor: replies.nextCursor,
265
+ })
266
+ }
267
+
268
+ case 'get_notifications': {
269
+ const agent = await getScopedAgent(agentId)
270
+ const result = await getNotifications(agent.swarmfeedApiKey!, { limit: input.limit || 20 })
271
+ return JSON.stringify(result)
272
+ }
273
+
274
+ case 'get_profile': {
275
+ if (!input.targetAgentId) return JSON.stringify({ error: 'targetAgentId is required for get_profile action' })
276
+ return JSON.stringify(await getProfile(input.targetAgentId))
277
+ }
278
+
279
+ case 'get_suggested_follows': {
280
+ const agent = await getScopedAgent(agentId)
281
+ return JSON.stringify(await getSuggestedFollows(agent.swarmfeedApiKey!, input.limit || 5))
117
282
  }
118
283
 
119
284
  default:
@@ -121,13 +286,12 @@ async function executeSwarmFeed(input: SwarmFeedInput, bctx: ToolBuildContext):
121
286
  }
122
287
  } catch (err: unknown) {
123
288
  const message = err instanceof Error ? err.message : 'Unknown error'
124
- log.error(TAG, `Action "${input.action}" failed for agent "${agent.name}": ${message}`)
289
+ log.error(TAG, `Action "${input.action}" failed for agent "${storedAgent.name}": ${message}`)
125
290
  return JSON.stringify({ error: message })
126
291
  }
127
292
  }
128
293
 
129
294
  export function buildSwarmFeedTools(bctx: ToolBuildContext): StructuredToolInterface[] {
130
- // Only provide tool if the agent has SwarmFeed enabled
131
295
  const agentId = bctx.ctx?.agentId
132
296
  if (!agentId) return []
133
297
 
@@ -141,8 +305,7 @@ export function buildSwarmFeedTools(bctx: ToolBuildContext): StructuredToolInter
141
305
  name: 'swarmfeed',
142
306
  description:
143
307
  'Interact with SwarmFeed, the social network for AI agents. ' +
144
- 'Actions: post (publish a post), reply (reply to a post), like, repost, ' +
145
- 'browse_feed (read the feed), get_channels (list available channels).',
308
+ 'Actions include posting, replying, quote reposting, liking, bookmarking, following, searching, browsing feeds, reading threads, checking notifications, and viewing profiles.',
146
309
  schema: SWARMFEED_SCHEMA,
147
310
  },
148
311
  ),
@@ -526,6 +526,7 @@ function normalizeStoredRecordInner(
526
526
  if (!Array.isArray(agent.swarmfeedAutoPostChannels)) agent.swarmfeedAutoPostChannels = []
527
527
  if (typeof agent.swarmfeedApiKey !== 'string' && agent.swarmfeedApiKey !== null) agent.swarmfeedApiKey = null
528
528
  if (typeof agent.swarmfeedAgentId !== 'string' && agent.swarmfeedAgentId !== null) agent.swarmfeedAgentId = null
529
+ if (typeof agent.swarmfeedLastAutoPostAt !== 'number' && agent.swarmfeedLastAutoPostAt !== null) agent.swarmfeedLastAutoPostAt = null
529
530
  if (!agent.origin) agent.origin = 'swarmclaw'
530
531
  if (agent.swarmfeedHeartbeat === undefined) agent.swarmfeedHeartbeat = null
531
532
  // SwarmDock defaults
@@ -0,0 +1,188 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { after, before, describe, it } from 'node:test'
6
+ import type { Agent } 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 runtimeMod: typeof import('./swarmfeed-runtime')
17
+ let agentRepoMod: typeof import('./agents/agent-repository')
18
+
19
+ function makeAgent(overrides: Partial<Agent> = {}): Agent {
20
+ return {
21
+ id: 'agent-1',
22
+ name: 'Test Agent',
23
+ description: 'Social test agent',
24
+ systemPrompt: 'Be useful',
25
+ provider: 'openai',
26
+ model: 'gpt-4o-mini',
27
+ createdAt: Date.now(),
28
+ updatedAt: Date.now(),
29
+ swarmfeedEnabled: true,
30
+ heartbeatEnabled: true,
31
+ swarmfeedAutoPostChannels: ['builders'],
32
+ swarmfeedHeartbeat: {
33
+ enabled: true,
34
+ browseFeed: true,
35
+ postFrequency: 'manual_only',
36
+ autoReply: false,
37
+ autoFollow: false,
38
+ channelsToMonitor: ['builders'],
39
+ },
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ before(async () => {
45
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-swarmfeed-runtime-'))
46
+ workspaceDir = path.join(tempDir, 'workspace')
47
+ fs.mkdirSync(workspaceDir, { recursive: true })
48
+ process.env.DATA_DIR = path.join(tempDir, 'data')
49
+ process.env.WORKSPACE_DIR = workspaceDir
50
+ process.env.SWARMCLAW_BUILD_MODE = '1'
51
+
52
+ runtimeMod = await import('./swarmfeed-runtime')
53
+ agentRepoMod = await import('./agents/agent-repository')
54
+ })
55
+
56
+ after(() => {
57
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
58
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
59
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
60
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
61
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
62
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
63
+ fs.rmSync(tempDir, { recursive: true, force: true })
64
+ })
65
+
66
+ describe('buildSwarmFeedHeartbeatGuidance', () => {
67
+ it('returns empty string when SwarmFeed social heartbeat is disabled', () => {
68
+ const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
69
+ makeAgent({ swarmfeedEnabled: false }),
70
+ )
71
+ assert.equal(guidance, '')
72
+ })
73
+
74
+ it('explains that social automation is inactive when the main heartbeat is disabled', () => {
75
+ const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
76
+ makeAgent({ heartbeatEnabled: false }),
77
+ )
78
+ assert.match(guidance, /currently inactive/i)
79
+ assert.match(guidance, /heartbeat is disabled/i)
80
+ })
81
+
82
+ it('includes the manual-only guardrail for autonomous posting', () => {
83
+ const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
84
+ makeAgent({
85
+ swarmfeedHeartbeat: {
86
+ enabled: true,
87
+ browseFeed: false,
88
+ postFrequency: 'manual_only',
89
+ autoReply: false,
90
+ autoFollow: false,
91
+ channelsToMonitor: [],
92
+ },
93
+ }),
94
+ )
95
+ assert.match(guidance, /manual only/i)
96
+ assert.match(guidance, /Do not author new SwarmFeed posts or replies/i)
97
+ })
98
+
99
+ it('mentions the recent-post limit for daily posting', () => {
100
+ const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
101
+ makeAgent({
102
+ swarmfeedLastAutoPostAt: Date.now() - 60_000,
103
+ swarmfeedHeartbeat: {
104
+ enabled: true,
105
+ browseFeed: true,
106
+ postFrequency: 'daily',
107
+ autoReply: true,
108
+ autoFollow: true,
109
+ channelsToMonitor: ['builders'],
110
+ },
111
+ }),
112
+ )
113
+ assert.match(guidance, /daily auto-post already happened/i)
114
+ })
115
+
116
+ it('describes the on-task-completion policy explicitly', () => {
117
+ const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance(
118
+ makeAgent({
119
+ swarmfeedHeartbeat: {
120
+ enabled: true,
121
+ browseFeed: true,
122
+ postFrequency: 'on_task_completion',
123
+ autoReply: false,
124
+ autoFollow: false,
125
+ channelsToMonitor: ['builders'],
126
+ },
127
+ }),
128
+ )
129
+ assert.match(guidance, /on task completion/i)
130
+ assert.match(guidance, /completed task/i)
131
+ })
132
+ })
133
+
134
+ describe('canAutoPostToSwarmFeed', () => {
135
+ it('blocks autonomous posting when manual_only is configured', () => {
136
+ const result = runtimeMod.canAutoPostToSwarmFeed(makeAgent())
137
+ assert.equal(result.allowed, false)
138
+ assert.match(result.reason || '', /manual_only/i)
139
+ })
140
+
141
+ it('blocks autonomous posting after a recent daily post', () => {
142
+ const result = runtimeMod.canAutoPostToSwarmFeed(
143
+ makeAgent({
144
+ swarmfeedLastAutoPostAt: Date.now() - 60_000,
145
+ swarmfeedHeartbeat: {
146
+ enabled: true,
147
+ browseFeed: true,
148
+ postFrequency: 'daily',
149
+ autoReply: false,
150
+ autoFollow: false,
151
+ channelsToMonitor: [],
152
+ },
153
+ }),
154
+ )
155
+ assert.equal(result.allowed, false)
156
+ assert.match(result.reason || '', /daily autonomous SwarmFeed post/i)
157
+ })
158
+
159
+ it('allows daily posting when the last automatic post is older than 24 hours', () => {
160
+ const result = runtimeMod.canAutoPostToSwarmFeed(
161
+ makeAgent({
162
+ swarmfeedLastAutoPostAt: Date.now() - (25 * 60 * 60 * 1000),
163
+ swarmfeedHeartbeat: {
164
+ enabled: true,
165
+ browseFeed: true,
166
+ postFrequency: 'daily',
167
+ autoReply: false,
168
+ autoFollow: false,
169
+ channelsToMonitor: [],
170
+ },
171
+ }),
172
+ )
173
+ assert.equal(result.allowed, true)
174
+ })
175
+ })
176
+
177
+ describe('markSwarmFeedAutoPost', () => {
178
+ it('persists the last auto-post timestamp on the agent record', () => {
179
+ const agent = makeAgent({ id: 'agent-mark-post' })
180
+ agentRepoMod.saveAgent(agent.id, agent)
181
+
182
+ runtimeMod.markSwarmFeedAutoPost(agent.id)
183
+
184
+ const updated = agentRepoMod.getAgent(agent.id)
185
+ assert.equal(typeof updated?.swarmfeedLastAutoPostAt, 'number')
186
+ assert.ok((updated?.swarmfeedLastAutoPostAt || 0) >= agent.createdAt)
187
+ })
188
+ })