@swarmclawai/swarmclaw 0.8.4 → 0.8.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -0,0 +1,151 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import {
4
+ stripBlockedItems,
5
+ isHeartbeatContentEffectivelyEmpty,
6
+ buildAgentHeartbeatPrompt,
7
+ } from './heartbeat-service'
8
+
9
+ describe('heartbeat blocked-item suppression', () => {
10
+ describe('stripBlockedItems', () => {
11
+ it('removes checklist items marked (blocked, no update)', () => {
12
+ const input = [
13
+ '# Heartbeat Tasks',
14
+ '## Active',
15
+ '- [ ] Pull SWGOH roster data (blocked, no update)',
16
+ '- [ ] Send daily summary',
17
+ '## Completed',
18
+ '- [x] Do laundry',
19
+ ].join('\n')
20
+
21
+ const result = stripBlockedItems(input)
22
+
23
+ assert.ok(!result.includes('SWGOH'), 'blocked item should be stripped')
24
+ assert.ok(result.includes('Send daily summary'), 'non-blocked item should remain')
25
+ assert.ok(result.includes('Do laundry'), 'completed item should remain')
26
+ assert.ok(result.includes('# Heartbeat Tasks'), 'headers should remain')
27
+ })
28
+
29
+ it('removes items with various blocked markers', () => {
30
+ const input = [
31
+ '- [ ] Task A (blocked: awaiting input)',
32
+ '- [ ] Task B (Blocked, pending user decision)',
33
+ '- [ ] Task C (BLOCKED)',
34
+ '- [ ] Task D - normal task',
35
+ '* [ ] Task E (blocked, no update)',
36
+ ].join('\n')
37
+
38
+ const result = stripBlockedItems(input)
39
+
40
+ assert.ok(!result.includes('Task A'), 'blocked: variant stripped')
41
+ assert.ok(!result.includes('Task B'), 'Blocked, variant stripped')
42
+ assert.ok(!result.includes('Task C'), 'BLOCKED variant stripped')
43
+ assert.ok(result.includes('Task D'), 'non-blocked task preserved')
44
+ assert.ok(!result.includes('Task E'), 'asterisk list item stripped')
45
+ })
46
+
47
+ it('preserves non-list lines that mention "blocked"', () => {
48
+ const input = [
49
+ '## Notes',
50
+ 'Some items are blocked until user responds.',
51
+ '- [ ] Actual blocked task (blocked, no update)',
52
+ ].join('\n')
53
+
54
+ const result = stripBlockedItems(input)
55
+
56
+ assert.ok(result.includes('Some items are blocked'), 'prose mentioning blocked should stay')
57
+ assert.ok(!result.includes('Actual blocked task'), 'blocked list item should be stripped')
58
+ })
59
+
60
+ it('returns empty string for empty input', () => {
61
+ assert.equal(stripBlockedItems(''), '')
62
+ assert.equal(stripBlockedItems(null as unknown as string), '')
63
+ })
64
+
65
+ it('returns content unchanged when no blocked items', () => {
66
+ const input = '- [ ] Task A\n- [ ] Task B\n'
67
+ assert.equal(stripBlockedItems(input), input)
68
+ })
69
+ })
70
+
71
+ describe('blocked items + effectively empty', () => {
72
+ it('treats content with only blocked items as effectively empty', () => {
73
+ const input = [
74
+ '# Heartbeat Tasks',
75
+ '## Active',
76
+ '- [ ] Pull SWGOH data (blocked, no update)',
77
+ '## Completed',
78
+ ].join('\n')
79
+
80
+ const stripped = stripBlockedItems(input)
81
+ // After stripping, only headers remain — effectively empty
82
+ assert.equal(isHeartbeatContentEffectivelyEmpty(stripped), true)
83
+ })
84
+
85
+ it('treats content with blocked + active items as not empty', () => {
86
+ const input = [
87
+ '# Heartbeat Tasks',
88
+ '## Active',
89
+ '- [ ] Pull SWGOH data (blocked, no update)',
90
+ '- [ ] Send daily summary',
91
+ ].join('\n')
92
+
93
+ const stripped = stripBlockedItems(input)
94
+ assert.equal(isHeartbeatContentEffectivelyEmpty(stripped), false)
95
+ })
96
+ })
97
+
98
+ describe('buildAgentHeartbeatPrompt integration', () => {
99
+ it('does not include blocked items in the prompt sent to the LLM', () => {
100
+ const session = {
101
+ id: 'test-session',
102
+ cwd: '/tmp',
103
+ messages: [],
104
+ }
105
+ const agent = {
106
+ id: 'test-agent',
107
+ name: 'Test',
108
+ description: 'Test agent',
109
+ }
110
+
111
+ const heartbeatFileContent = [
112
+ '# Heartbeat Tasks',
113
+ '## Active',
114
+ '- [ ] Pull SWGOH roster data (blocked, no update)',
115
+ '- [ ] Check weather forecast',
116
+ '## Completed',
117
+ '- [x] Laundry done',
118
+ ].join('\n')
119
+
120
+ const prompt = buildAgentHeartbeatPrompt(session, agent, 'default prompt', heartbeatFileContent)
121
+
122
+ assert.ok(!prompt.includes('SWGOH'), 'blocked SWGOH task should not appear in prompt')
123
+ assert.ok(prompt.includes('Check weather forecast'), 'non-blocked task should appear')
124
+ assert.ok(prompt.includes('Laundry done'), 'completed task should appear')
125
+ })
126
+
127
+ it('produces no HEARTBEAT.md section when all active items are blocked', () => {
128
+ const session = {
129
+ id: 'test-session',
130
+ cwd: '/tmp',
131
+ messages: [],
132
+ }
133
+ const agent = {
134
+ id: 'test-agent',
135
+ name: 'Test',
136
+ description: 'Test agent',
137
+ }
138
+
139
+ const heartbeatFileContent = [
140
+ '# Heartbeat Tasks',
141
+ '## Active',
142
+ '- [ ] Task A (blocked, no update)',
143
+ '- [ ] Task B (blocked: awaiting user)',
144
+ ].join('\n')
145
+
146
+ const prompt = buildAgentHeartbeatPrompt(session, agent, 'default prompt', heartbeatFileContent)
147
+
148
+ assert.ok(!prompt.includes('HEARTBEAT.md contents:'), 'should not include HEARTBEAT.md section when all items are blocked')
149
+ })
150
+ })
151
+ })
@@ -194,6 +194,31 @@ export function buildIdentityContext(session: Record<string, unknown> | undefine
194
194
  return `## Your Identity\n${lines.join('\n')}`
195
195
  }
196
196
 
197
+ // ── Blocked-item suppression ────────────────────────────────────────────
198
+ // Ported from OpenClaw's duplicate-suppression pattern: instead of letting
199
+ // the LLM see blocked tasks every tick (and parrot "still blocked"), we
200
+ // strip those lines before they ever reach the prompt. A line is
201
+ // considered blocked if it contains "(blocked" anywhere (case-insensitive),
202
+ // which covers "(blocked, no update)", "(blocked: awaiting …)", etc.
203
+ const BLOCKED_MARKER_RE = /\(blocked\b/i
204
+
205
+ /**
206
+ * Remove blocked checklist items from HEARTBEAT.md content so the LLM
207
+ * doesn't keep surfacing them. Headers and non-list lines pass through
208
+ * unchanged.
209
+ */
210
+ export function stripBlockedItems(content: string): string {
211
+ if (!content) return ''
212
+ const lines = content.split('\n')
213
+ const filtered = lines.filter((line) => {
214
+ const trimmed = line.trim()
215
+ // Only filter checklist / list items that are explicitly marked blocked
216
+ if (/^[-*+]\s/.test(trimmed) && BLOCKED_MARKER_RE.test(trimmed)) return false
217
+ return true
218
+ })
219
+ return filtered.join('\n')
220
+ }
221
+
197
222
  /** Detect HEARTBEAT.md files that contain only skeleton structure (headers, empty list items) but no real content. */
198
223
  export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
199
224
  if (!content || typeof content !== 'string') return true
@@ -236,8 +261,9 @@ export function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackProm
236
261
  })
237
262
  .join('\n')
238
263
 
239
- // Don't inject effectively-empty HEARTBEAT.md content
240
- const effectiveFileContent = isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) ? '' : heartbeatFileContent
264
+ // Strip blocked items, then check if anything meaningful remains
265
+ const strippedContent = stripBlockedItems(heartbeatFileContent)
266
+ const effectiveFileContent = isHeartbeatContentEffectivelyEmpty(strippedContent) ? '' : strippedContent
241
267
 
242
268
  return [
243
269
  'AGENT_HEARTBEAT_TICK',