@swarmclawai/swarmclaw 1.5.53 → 1.5.55

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 (48) hide show
  1. package/README.md +17 -3
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +14 -2
  4. package/src/app/api/agents/agents-route.test.ts +65 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
  6. package/src/app/api/chatrooms/route.ts +3 -0
  7. package/src/app/api/missions/[id]/control/route.ts +21 -0
  8. package/src/app/api/missions/templates/[id]/instantiate/route.ts +64 -0
  9. package/src/app/api/missions/templates/route.ts +8 -0
  10. package/src/app/api/tasks/[id]/route.ts +11 -1
  11. package/src/app/api/tasks/tasks-route.test.ts +81 -0
  12. package/src/app/api/webhooks/[id]/route.ts +18 -15
  13. package/src/app/missions/page.tsx +135 -22
  14. package/src/cli/index.js +2 -0
  15. package/src/cli/spec.js +2 -0
  16. package/src/components/missions/mission-edit-sheet.tsx +319 -0
  17. package/src/components/missions/mission-template-gallery.tsx +113 -0
  18. package/src/components/missions/mission-template-install-dialog.tsx +283 -0
  19. package/src/lib/server/agents/agent-service.ts +10 -2
  20. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
  21. package/src/lib/server/agents/main-agent-loop.ts +111 -4
  22. package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
  23. package/src/lib/server/chat-execution/chat-turn-preparation.ts +46 -26
  24. package/src/lib/server/chat-execution/message-classifier.ts +11 -7
  25. package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
  26. package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
  27. package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
  28. package/src/lib/server/chat-execution/response-completeness.ts +11 -3
  29. package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
  30. package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
  31. package/src/lib/server/chats/chat-session-service.ts +11 -0
  32. package/src/lib/server/connectors/email.test.ts +64 -0
  33. package/src/lib/server/connectors/email.ts +35 -6
  34. package/src/lib/server/connectors/response-media.ts +1 -0
  35. package/src/lib/server/daemon/daemon-runtime.ts +31 -19
  36. package/src/lib/server/memory/memory-db.test.ts +8 -0
  37. package/src/lib/server/memory/memory-db.ts +1 -1
  38. package/src/lib/server/missions/mission-service.ts +47 -1
  39. package/src/lib/server/missions/mission-templates.test.ts +208 -0
  40. package/src/lib/server/missions/mission-templates.ts +186 -0
  41. package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
  42. package/src/lib/server/storage-normalization.ts +6 -0
  43. package/src/lib/server/storage.ts +1 -1
  44. package/src/lib/server/tasks/task-validation.test.ts +30 -0
  45. package/src/lib/server/tasks/task-validation.ts +21 -2
  46. package/src/lib/server/working-state/normalization.ts +5 -1
  47. package/src/lib/validation/schemas.ts +40 -0
  48. package/src/types/mission.ts +27 -0
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import type { MissionTemplate, MissionTemplateCategory } from '@/types'
5
+
6
+ const CATEGORY_LABELS: Record<MissionTemplateCategory, string> = {
7
+ research: 'Research',
8
+ communication: 'Communication',
9
+ monitoring: 'Monitoring',
10
+ productivity: 'Productivity',
11
+ support: 'Support',
12
+ }
13
+
14
+ interface Props {
15
+ templates: MissionTemplate[]
16
+ onInstall: (template: MissionTemplate) => void
17
+ }
18
+
19
+ export function MissionTemplateGallery({ templates, onInstall }: Props) {
20
+ const [category, setCategory] = useState<'all' | MissionTemplateCategory>('all')
21
+
22
+ const categories = useMemo(() => {
23
+ const seen = new Set<MissionTemplateCategory>()
24
+ for (const template of templates) seen.add(template.category)
25
+ return Array.from(seen)
26
+ }, [templates])
27
+
28
+ const filtered = useMemo(() => {
29
+ if (category === 'all') return templates
30
+ return templates.filter((t) => t.category === category)
31
+ }, [templates, category])
32
+
33
+ return (
34
+ <div className="flex flex-col gap-4">
35
+ <div>
36
+ <h2 className="text-[15px] font-700 text-text">Start from a template</h2>
37
+ <p className="text-[12px] text-text-3 mt-1">
38
+ Pre-wired goals, budgets, and report schedules. Tweak anything before you install.
39
+ </p>
40
+ </div>
41
+
42
+ <div className="flex flex-wrap gap-1.5">
43
+ <FilterChip active={category === 'all'} onClick={() => setCategory('all')} label="All" />
44
+ {categories.map((c) => (
45
+ <FilterChip
46
+ key={c}
47
+ active={category === c}
48
+ onClick={() => setCategory(c)}
49
+ label={CATEGORY_LABELS[c]}
50
+ />
51
+ ))}
52
+ </div>
53
+
54
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
55
+ {filtered.map((template) => (
56
+ <TemplateCard key={template.id} template={template} onInstall={() => onInstall(template)} />
57
+ ))}
58
+ </div>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ function FilterChip({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
64
+ return (
65
+ <button
66
+ type="button"
67
+ onClick={onClick}
68
+ className={`text-[11px] font-600 px-2.5 py-1 rounded-full border transition-colors ${
69
+ active
70
+ ? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-200'
71
+ : 'border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text'
72
+ }`}
73
+ >
74
+ {label}
75
+ </button>
76
+ )
77
+ }
78
+
79
+ function TemplateCard({ template, onInstall }: { template: MissionTemplate; onInstall: () => void }) {
80
+ return (
81
+ <button
82
+ type="button"
83
+ onClick={onInstall}
84
+ className="text-left group flex flex-col gap-3 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4 transition-all hover:border-white/[0.16] hover:bg-white/[0.04]"
85
+ >
86
+ <div className="flex items-start gap-3">
87
+ <span className="text-[22px] leading-none" aria-hidden>{template.icon}</span>
88
+ <div className="flex-1 min-w-0">
89
+ <div className="text-[13px] font-700 text-text">{template.name}</div>
90
+ <div className="text-[10px] uppercase tracking-wide text-text-3/70 mt-0.5">
91
+ {CATEGORY_LABELS[template.category]}
92
+ </div>
93
+ </div>
94
+ </div>
95
+ <div className="text-[12px] text-text-3 leading-[1.55] line-clamp-3">{template.description}</div>
96
+ <div className="flex items-center justify-between mt-auto">
97
+ <div className="flex flex-wrap gap-1">
98
+ {template.tags.slice(0, 3).map((tag) => (
99
+ <span
100
+ key={tag}
101
+ className="text-[10px] text-text-3/70 px-1.5 py-0.5 rounded border border-white/[0.06] bg-white/[0.02]"
102
+ >
103
+ {tag}
104
+ </span>
105
+ ))}
106
+ </div>
107
+ <span className="text-[11px] font-600 text-emerald-300 group-hover:text-emerald-200">
108
+ Install →
109
+ </span>
110
+ </div>
111
+ </button>
112
+ )
113
+ }
@@ -0,0 +1,283 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { HintTip } from '@/components/shared/hint-tip'
5
+ import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section'
6
+ import { inputClass } from '@/components/shared/form-styles'
7
+ import type { MissionTemplate, Session } from '@/types'
8
+ import { toast } from 'sonner'
9
+
10
+ export interface InstantiateInput {
11
+ rootSessionId: string
12
+ overrides: {
13
+ title: string
14
+ goal: string
15
+ successCriteria: string[]
16
+ budget: {
17
+ maxUsd: number | null
18
+ maxTokens: number | null
19
+ maxWallclockSec: number | null
20
+ maxTurns: number | null
21
+ }
22
+ reportSchedule: { intervalSec: number; format: 'markdown'; enabled: boolean } | null
23
+ }
24
+ }
25
+
26
+ interface Props {
27
+ template: MissionTemplate | null
28
+ sessions: Session[]
29
+ onClose: () => void
30
+ onInstall: (template: MissionTemplate, input: InstantiateInput) => Promise<void>
31
+ }
32
+
33
+ function formatDuration(sec: number | null | undefined): string {
34
+ if (sec == null) return '-'
35
+ if (sec < 60) return `${sec}s`
36
+ if (sec < 3600) return `${Math.round(sec / 60)}m`
37
+ if (sec < 86_400) return `${Math.round(sec / 3600)}h`
38
+ return `${Math.round(sec / 86_400)}d`
39
+ }
40
+
41
+ function numOrNull(s: string): number | null {
42
+ const n = Number.parseFloat(s)
43
+ return Number.isFinite(n) && n > 0 ? n : null
44
+ }
45
+
46
+ function intOrNull(s: string): number | null {
47
+ const n = numOrNull(s)
48
+ return n == null ? null : Math.round(n)
49
+ }
50
+
51
+ export function MissionTemplateInstallDialog({ template, sessions, onClose, onInstall }: Props) {
52
+ const [title, setTitle] = useState('')
53
+ const [goal, setGoal] = useState('')
54
+ const [criteriaText, setCriteriaText] = useState('')
55
+ const [rootSessionId, setRootSessionId] = useState('')
56
+ const [advancedOpen, setAdvancedOpen] = useState(false)
57
+ const [maxUsd, setMaxUsd] = useState('')
58
+ const [maxTokens, setMaxTokens] = useState('')
59
+ const [maxWallclockSec, setMaxWallclockSec] = useState('')
60
+ const [maxTurns, setMaxTurns] = useState('')
61
+ const [reportsEnabled, setReportsEnabled] = useState(true)
62
+ const [reportIntervalMin, setReportIntervalMin] = useState('')
63
+ const [busy, setBusy] = useState(false)
64
+
65
+ useEffect(() => {
66
+ if (!template) return
67
+ setTitle(template.defaults.title)
68
+ setGoal(template.defaults.goal)
69
+ setCriteriaText(template.defaults.successCriteria.join('\n'))
70
+ setMaxUsd(template.defaults.budget.maxUsd != null ? String(template.defaults.budget.maxUsd) : '')
71
+ setMaxTokens(template.defaults.budget.maxTokens != null ? String(template.defaults.budget.maxTokens) : '')
72
+ setMaxWallclockSec(template.defaults.budget.maxWallclockSec != null ? String(template.defaults.budget.maxWallclockSec) : '')
73
+ setMaxTurns(template.defaults.budget.maxTurns != null ? String(template.defaults.budget.maxTurns) : '')
74
+ setReportsEnabled(template.defaults.reportSchedule?.enabled ?? false)
75
+ setReportIntervalMin(
76
+ template.defaults.reportSchedule?.intervalSec != null
77
+ ? String(Math.round(template.defaults.reportSchedule.intervalSec / 60))
78
+ : '60',
79
+ )
80
+ setAdvancedOpen(false)
81
+ }, [template])
82
+
83
+ useEffect(() => {
84
+ if (!rootSessionId && sessions.length > 0) setRootSessionId(sessions[0].id)
85
+ }, [sessions, rootSessionId])
86
+
87
+ const badges = useMemo(() => {
88
+ if (!template) return []
89
+ const out: string[] = []
90
+ if (template.defaults.budget.maxUsd != null) out.push(`$${template.defaults.budget.maxUsd} cap`)
91
+ if (template.defaults.budget.maxTurns != null) out.push(`${template.defaults.budget.maxTurns} turns`)
92
+ if (template.defaults.budget.maxWallclockSec != null) out.push(formatDuration(template.defaults.budget.maxWallclockSec))
93
+ if (template.defaults.reportSchedule) out.push(`Reports every ${formatDuration(template.defaults.reportSchedule.intervalSec)}`)
94
+ return out
95
+ }, [template])
96
+
97
+ if (!template) return null
98
+
99
+ const submit = async () => {
100
+ if (!rootSessionId) {
101
+ toast.error('Pick a session to drive this mission')
102
+ return
103
+ }
104
+ if (!title.trim() || !goal.trim()) {
105
+ toast.error('Title and goal are required')
106
+ return
107
+ }
108
+ setBusy(true)
109
+ try {
110
+ const successCriteria = criteriaText.split('\n').map((s) => s.trim()).filter(Boolean)
111
+ const intervalMin = numOrNull(reportIntervalMin) ?? 60
112
+ await onInstall(template, {
113
+ rootSessionId,
114
+ overrides: {
115
+ title: title.trim(),
116
+ goal: goal.trim(),
117
+ successCriteria,
118
+ budget: {
119
+ maxUsd: numOrNull(maxUsd),
120
+ maxTokens: intOrNull(maxTokens),
121
+ maxWallclockSec: intOrNull(maxWallclockSec),
122
+ maxTurns: intOrNull(maxTurns),
123
+ },
124
+ reportSchedule: reportsEnabled
125
+ ? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
126
+ : null,
127
+ },
128
+ })
129
+ onClose()
130
+ } catch (error) {
131
+ toast.error(`Install failed: ${error instanceof Error ? error.message : String(error)}`)
132
+ } finally {
133
+ setBusy(false)
134
+ }
135
+ }
136
+
137
+ return (
138
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
139
+ <div
140
+ className="w-full max-w-xl rounded-[14px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-5 max-h-[92vh] overflow-y-auto"
141
+ onClick={(e) => e.stopPropagation()}
142
+ >
143
+ <div className="flex items-start gap-3 mb-4">
144
+ <span className="text-[28px] leading-none" aria-hidden>{template.icon}</span>
145
+ <div className="min-w-0 flex-1">
146
+ <div className="text-[15px] font-700 text-text">{template.name}</div>
147
+ <div className="text-[12px] text-text-3 leading-[1.5] mt-1">{template.description}</div>
148
+ {badges.length > 0 && (
149
+ <div className="mt-2.5 flex flex-wrap gap-1.5">
150
+ {badges.map((badge) => (
151
+ <span
152
+ key={badge}
153
+ className="text-[10px] font-600 px-1.5 py-0.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3"
154
+ >
155
+ {badge}
156
+ </span>
157
+ ))}
158
+ </div>
159
+ )}
160
+ </div>
161
+ </div>
162
+
163
+ {template.setupNote && (
164
+ <div className="mb-4 text-[11px] text-amber-200/90 bg-amber-500/10 border border-amber-500/20 rounded-[10px] px-3 py-2 leading-[1.5]">
165
+ <span className="font-700">Setup: </span>
166
+ {template.setupNote}
167
+ </div>
168
+ )}
169
+
170
+ <div className="flex flex-col gap-3">
171
+ <label className="flex flex-col gap-1">
172
+ <span className="text-[11px] text-text-3">Title</span>
173
+ <input value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
174
+ </label>
175
+
176
+ <label className="flex flex-col gap-1">
177
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
178
+ Goal <HintTip text="The natural-language objective your team will work on." />
179
+ </span>
180
+ <textarea
181
+ value={goal}
182
+ onChange={(e) => setGoal(e.target.value)}
183
+ rows={4}
184
+ className={`${inputClass} resize-none`}
185
+ />
186
+ </label>
187
+
188
+ <label className="flex flex-col gap-1">
189
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
190
+ Root session <HintTip text="The session whose heartbeat drives this mission." />
191
+ </span>
192
+ <select value={rootSessionId} onChange={(e) => setRootSessionId(e.target.value)} className={inputClass}>
193
+ {sessions.length === 0 && <option value="">No sessions available. Create a chat first.</option>}
194
+ {sessions.map((s) => (
195
+ <option key={s.id} value={s.id}>
196
+ {s.name || s.id}
197
+ </option>
198
+ ))}
199
+ </select>
200
+ </label>
201
+ </div>
202
+
203
+ <div className="mt-4">
204
+ <AdvancedSettingsSection
205
+ open={advancedOpen}
206
+ onToggle={() => setAdvancedOpen((o) => !o)}
207
+ summary="Budgets, criteria, reports"
208
+ >
209
+ <div className="flex flex-col gap-4">
210
+ <label className="flex flex-col gap-1">
211
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
212
+ Success criteria <HintTip text="One per line. Used in reports and final verification." />
213
+ </span>
214
+ <textarea
215
+ value={criteriaText}
216
+ onChange={(e) => setCriteriaText(e.target.value)}
217
+ rows={4}
218
+ className={`${inputClass} resize-none`}
219
+ />
220
+ </label>
221
+
222
+ <div className="grid grid-cols-2 gap-3">
223
+ <label className="flex flex-col gap-1">
224
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
225
+ Max USD <HintTip text="Hard spend cap. Leave blank for no limit." />
226
+ </span>
227
+ <input value={maxUsd} onChange={(e) => setMaxUsd(e.target.value)} className={inputClass} inputMode="decimal" />
228
+ </label>
229
+ <label className="flex flex-col gap-1">
230
+ <span className="text-[11px] text-text-3">Max tokens</span>
231
+ <input value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} className={inputClass} inputMode="numeric" />
232
+ </label>
233
+ <label className="flex flex-col gap-1">
234
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
235
+ Max wallclock (sec) <HintTip text="Scheduler aborts after this many seconds of elapsed wallclock." />
236
+ </span>
237
+ <input value={maxWallclockSec} onChange={(e) => setMaxWallclockSec(e.target.value)} className={inputClass} inputMode="numeric" />
238
+ </label>
239
+ <label className="flex flex-col gap-1">
240
+ <span className="text-[11px] text-text-3">Max turns</span>
241
+ <input value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} className={inputClass} inputMode="numeric" />
242
+ </label>
243
+ </div>
244
+
245
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
246
+ <div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-1.5">Periodic reports</div>
247
+ <label className="flex items-center gap-2 flex-wrap">
248
+ <input type="checkbox" checked={reportsEnabled} onChange={(e) => setReportsEnabled(e.target.checked)} />
249
+ <span className="text-[11px] text-text-3">Send a markdown progress report every</span>
250
+ <input
251
+ disabled={!reportsEnabled}
252
+ value={reportIntervalMin}
253
+ onChange={(e) => setReportIntervalMin(e.target.value)}
254
+ className={`${inputClass} w-16`}
255
+ inputMode="numeric"
256
+ />
257
+ <span className="text-[11px] text-text-3">minutes</span>
258
+ </label>
259
+ </div>
260
+ </div>
261
+ </AdvancedSettingsSection>
262
+ </div>
263
+
264
+ <div className="mt-5 flex items-center justify-end gap-2">
265
+ <button
266
+ onClick={onClose}
267
+ className="text-[12px] px-3 py-1.5 rounded border border-white/[0.08] hover:bg-white/[0.04]"
268
+ disabled={busy}
269
+ >
270
+ Cancel
271
+ </button>
272
+ <button
273
+ onClick={submit}
274
+ disabled={busy}
275
+ className="text-[12px] font-600 px-3 py-1.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/25 disabled:opacity-40"
276
+ >
277
+ {busy ? 'Installing…' : 'Install mission'}
278
+ </button>
279
+ </div>
280
+ </div>
281
+ </div>
282
+ )
283
+ }
@@ -207,11 +207,19 @@ export function createAgent(input: {
207
207
  export function updateAgent(agentId: string, body: Record<string, unknown>): Agent | null {
208
208
  const updated = patchAgent(agentId, (current) => {
209
209
  if (!current) return null
210
+ if (body.projectId === undefined && Array.isArray(body.projectIds) && body.projectIds.length > 0) {
211
+ const first = body.projectIds[0]
212
+ if (typeof first === 'string' && first.trim()) {
213
+ body.projectId = first.trim()
214
+ }
215
+ }
210
216
  const agent = { ...current, ...body, updatedAt: Date.now() }
211
217
  if (body.tools !== undefined || body.extensions !== undefined) {
218
+ // Fall back to `current` (pre-spread) so a non-array body value does not
219
+ // clobber the existing list via `{...current, ...body}` above.
212
220
  const nextSelection = normalizeCapabilitySelection({
213
- tools: Array.isArray(body.tools) ? body.tools : agent.tools,
214
- extensions: Array.isArray(body.extensions) ? body.extensions : agent.extensions,
221
+ tools: Array.isArray(body.tools) ? body.tools : current.tools,
222
+ extensions: Array.isArray(body.extensions) ? body.extensions : current.extensions,
215
223
  })
216
224
  agent.tools = nextSelection.tools
217
225
  agent.extensions = nextSelection.extensions
@@ -668,6 +668,42 @@ describe('main-agent-loop advanced', () => {
668
668
  assert.equal(result, '')
669
669
  })
670
670
 
671
+ it('stripMainLoopMetaForPersistence strips trailing factsUpsert / isIncomplete blobs glued to assistant text', () => {
672
+ const input = 'The 8th Fibonacci number is **13**.{"isIncomplete": false, "confidence": 1.0}{"factsUpsert":[{"statement":"The 8th Fibonacci number is 13.","source":"assistant","status":"active","evidenceIds":[]}]}'
673
+ const result = stripMainLoopMetaForPersistence(input)
674
+ assert.ok(!result.includes('factsUpsert'), 'factsUpsert payload removed')
675
+ assert.ok(!result.includes('isIncomplete'), 'isIncomplete payload removed')
676
+ assert.ok(result.includes('The 8th Fibonacci number is **13**.'), 'visible answer preserved')
677
+ })
678
+
679
+ it('stripMainLoopMetaForPersistence preserves benign user-facing JSON snippets', () => {
680
+ const benign = [
681
+ 'Here is the example config you asked for:',
682
+ '{"port":3000,"host":"localhost","tls":{"enabled":true}}',
683
+ 'And a smaller variant:',
684
+ '{"status":"ok","goal":"render"}',
685
+ '{"confidence":0.9,"score":0.7}',
686
+ ].join('\n')
687
+ const result = stripMainLoopMetaForPersistence(benign)
688
+ assert.ok(result.includes('"port":3000'), 'config block preserved')
689
+ assert.ok(result.includes('"status":"ok"'), 'status snippet preserved')
690
+ assert.ok(result.includes('"confidence":0.9,"score":0.7'), 'confidence-only blob preserved')
691
+ })
692
+
693
+ it('stripMainLoopMetaForPersistence strips multi-line working-state JSON', () => {
694
+ const input = [
695
+ 'Here is the answer.',
696
+ '{',
697
+ ' "factsUpsert": [',
698
+ ' { "statement": "x", "source": "assistant", "status": "active", "evidenceIds": [] }',
699
+ ' ]',
700
+ '}',
701
+ ].join('\n')
702
+ const result = stripMainLoopMetaForPersistence(input)
703
+ assert.ok(!result.includes('factsUpsert'), 'multi-line blob removed')
704
+ assert.ok(result.includes('Here is the answer.'), 'visible answer preserved')
705
+ })
706
+
671
707
  // ─────────────────────────────────────────────────────────────────────
672
708
  // 9. Status transitions (direct import via subprocess for state access)
673
709
  // ─────────────────────────────────────────────────────────────────────
@@ -1113,22 +1113,129 @@ export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: s
1113
1113
  ].filter(Boolean).join('\n')
1114
1114
  }
1115
1115
 
1116
+ import { z } from 'zod'
1117
+ import { WorkingStatePatchSchema } from '@/lib/server/working-state/normalization'
1118
+ import { MessageClassificationSchema } from '@/lib/server/chat-execution/message-classifier'
1119
+ import { ResponseCompletenessSchema } from '@/lib/server/chat-execution/response-completeness'
1120
+
1121
+ // Side-channel classifier outputs that occasionally bleed into the visible
1122
+ // assistant text from cloud models. Each rule pairs a zod schema with at
1123
+ // least one distinctive field so unrelated JSON the user actually wants to
1124
+ // see (config blobs, API examples, snippets like {"status":"ok"}) stays in
1125
+ // the message instead of being silently scrubbed.
1126
+ const QualityScoreSchema = z.object({
1127
+ quality_score: z.number(),
1128
+ quality_reasoning: z.string(),
1129
+ }).passthrough()
1130
+
1131
+ interface InternalPayloadRule {
1132
+ schema: z.ZodType<unknown>
1133
+ distinctiveKeys: string[]
1134
+ }
1135
+
1136
+ const INTERNAL_PAYLOAD_RULES: InternalPayloadRule[] = [
1137
+ {
1138
+ schema: WorkingStatePatchSchema,
1139
+ distinctiveKeys: [
1140
+ 'factsUpsert',
1141
+ 'artifactsUpsert',
1142
+ 'planSteps',
1143
+ 'decisionsAppend',
1144
+ 'blockersUpsert',
1145
+ 'questionsUpsert',
1146
+ 'hypothesesUpsert',
1147
+ 'supersedeIds',
1148
+ ],
1149
+ },
1150
+ {
1151
+ schema: MessageClassificationSchema,
1152
+ distinctiveKeys: ['taskIntent', 'isLightweightDirectChat', 'isDeliverableTask'],
1153
+ },
1154
+ {
1155
+ schema: ResponseCompletenessSchema,
1156
+ distinctiveKeys: ['isIncomplete'],
1157
+ },
1158
+ {
1159
+ schema: QualityScoreSchema,
1160
+ distinctiveKeys: ['quality_score'],
1161
+ },
1162
+ ]
1163
+
1164
+ function objectIsInternalMetadata(obj: Record<string, unknown>): boolean {
1165
+ for (const { schema, distinctiveKeys } of INTERNAL_PAYLOAD_RULES) {
1166
+ if (!distinctiveKeys.some((key) => key in obj)) continue
1167
+ if (schema.safeParse(obj).success) return true
1168
+ }
1169
+ return false
1170
+ }
1171
+
1116
1172
  function isInternalMetadataJson(line: string): boolean {
1117
1173
  const trimmed = line.trim()
1118
1174
  if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false
1119
1175
  try {
1120
1176
  const obj = JSON.parse(trimmed)
1121
1177
  if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false
1122
- if ('isDeliverableTask' in obj && 'confidence' in obj) return true
1123
- if ('quality_score' in obj && 'quality_reasoning' in obj) return true
1124
- return false
1178
+ return objectIsInternalMetadata(obj as Record<string, unknown>)
1125
1179
  } catch {
1126
1180
  return false
1127
1181
  }
1128
1182
  }
1129
1183
 
1184
+ function findBalancedJsonObjectEnd(text: string, start: number): number {
1185
+ if (text.charAt(start) !== '{') return -1
1186
+ let depth = 0
1187
+ let inString = false
1188
+ let escaped = false
1189
+ for (let i = start; i < text.length; i += 1) {
1190
+ const c = text.charAt(i)
1191
+ if (inString) {
1192
+ if (escaped) escaped = false
1193
+ else if (c === '\\') escaped = true
1194
+ else if (c === '"') inString = false
1195
+ continue
1196
+ }
1197
+ if (c === '"') {
1198
+ inString = true
1199
+ continue
1200
+ }
1201
+ if (c === '{') depth += 1
1202
+ else if (c === '}') {
1203
+ depth -= 1
1204
+ if (depth === 0) return i + 1
1205
+ }
1206
+ }
1207
+ return -1
1208
+ }
1209
+
1210
+ function stripInternalJsonBlobs(text: string): string {
1211
+ let out = text || ''
1212
+ for (let guard = 0; guard < 16; guard += 1) {
1213
+ let removed = false
1214
+ for (let i = 0; i < out.length; i += 1) {
1215
+ if (out.charAt(i) !== '{') continue
1216
+ const end = findBalancedJsonObjectEnd(out, i)
1217
+ if (end <= i) continue
1218
+ const candidate = out.slice(i, end)
1219
+ let parsed: unknown
1220
+ try {
1221
+ parsed = JSON.parse(candidate)
1222
+ } catch {
1223
+ continue
1224
+ }
1225
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
1226
+ if (!objectIsInternalMetadata(parsed as Record<string, unknown>)) continue
1227
+ out = (out.slice(0, i).replace(/\s+$/, '') + ' ' + out.slice(end).replace(/^\s+/, '')).trim()
1228
+ removed = true
1229
+ break
1230
+ }
1231
+ if (!removed) break
1232
+ }
1233
+ return out
1234
+ }
1235
+
1130
1236
  export function stripMainLoopMetaForPersistence(text: string): string {
1131
- return (text || '')
1237
+ const withoutBlobs = stripInternalJsonBlobs(text || '')
1238
+ return withoutBlobs
1132
1239
  .split('\n')
1133
1240
  .filter((line) => !LEGACY_META_LINE_RE.test(line) && !isInternalMetadataJson(line))
1134
1241
  .join('\n')