@swarmclawai/swarmclaw 1.2.4 → 1.2.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.
Files changed (260) hide show
  1. package/README.md +14 -0
  2. package/bin/daemon-cmd.js +169 -0
  3. package/bin/server-cmd.js +3 -0
  4. package/bin/swarmclaw.js +11 -0
  5. package/package.json +17 -16
  6. package/src/app/api/agents/[id]/clone/route.ts +3 -32
  7. package/src/app/api/agents/[id]/route.ts +6 -158
  8. package/src/app/api/agents/[id]/status/route.ts +2 -3
  9. package/src/app/api/agents/[id]/thread/route.ts +4 -17
  10. package/src/app/api/agents/bulk/route.ts +5 -47
  11. package/src/app/api/agents/route.ts +5 -119
  12. package/src/app/api/agents/trash/route.ts +13 -24
  13. package/src/app/api/auth/route.ts +3 -9
  14. package/src/app/api/autonomy/estop/route.ts +5 -5
  15. package/src/app/api/chatrooms/[id]/chat/route.ts +11 -5
  16. package/src/app/api/chatrooms/[id]/route.ts +23 -2
  17. package/src/app/api/chatrooms/route.ts +13 -2
  18. package/src/app/api/chats/[id]/clear/route.ts +2 -13
  19. package/src/app/api/chats/[id]/deploy/route.ts +2 -3
  20. package/src/app/api/chats/[id]/edit-resend/route.ts +7 -13
  21. package/src/app/api/chats/[id]/mailbox/route.ts +6 -8
  22. package/src/app/api/chats/[id]/queue/route.ts +17 -64
  23. package/src/app/api/chats/[id]/retry/route.ts +4 -22
  24. package/src/app/api/chats/[id]/route.ts +10 -138
  25. package/src/app/api/chats/heartbeat/route.ts +2 -1
  26. package/src/app/api/chats/migrate-messages/route.ts +7 -0
  27. package/src/app/api/chats/route.ts +13 -134
  28. package/src/app/api/connectors/[id]/access/route.ts +12 -229
  29. package/src/app/api/connectors/[id]/doctor/route.ts +1 -1
  30. package/src/app/api/connectors/[id]/health/route.ts +12 -39
  31. package/src/app/api/connectors/[id]/route.ts +14 -122
  32. package/src/app/api/connectors/[id]/webhook/route.ts +1 -1
  33. package/src/app/api/connectors/doctor/route.ts +1 -1
  34. package/src/app/api/connectors/route.ts +12 -70
  35. package/src/app/api/credentials/[id]/route.ts +2 -4
  36. package/src/app/api/credentials/route.ts +10 -19
  37. package/src/app/api/daemon/health-check/route.ts +3 -4
  38. package/src/app/api/daemon/route.ts +10 -8
  39. package/src/app/api/documents/route.ts +11 -10
  40. package/src/app/api/external-agents/route.ts +3 -3
  41. package/src/app/api/gateways/[id]/health/route.ts +2 -3
  42. package/src/app/api/gateways/[id]/route.ts +7 -122
  43. package/src/app/api/gateways/route.ts +3 -103
  44. package/src/app/api/mcp-servers/[id]/tools/route.ts +5 -5
  45. package/src/app/api/openclaw/dashboard-url/route.ts +8 -16
  46. package/src/app/api/openclaw/directory/route.ts +2 -2
  47. package/src/app/api/openclaw/history/route.ts +3 -5
  48. package/src/app/api/providers/[id]/route.test.ts +49 -0
  49. package/src/app/api/providers/ollama/route.ts +6 -5
  50. package/src/app/api/schedules/[id]/route.ts +14 -108
  51. package/src/app/api/schedules/[id]/run/route.ts +6 -67
  52. package/src/app/api/schedules/route.ts +9 -51
  53. package/src/app/api/settings/route.ts +4 -3
  54. package/src/app/api/setup/check-provider/route.ts +15 -1
  55. package/src/app/api/setup/openclaw-device/route.ts +2 -2
  56. package/src/app/api/system/status/route.ts +2 -2
  57. package/src/app/api/tasks/[id]/route.ts +16 -202
  58. package/src/app/api/tasks/bulk/route.ts +5 -86
  59. package/src/app/api/tasks/metrics/route.ts +2 -1
  60. package/src/app/api/tasks/route.ts +11 -171
  61. package/src/app/api/upload/route.ts +1 -1
  62. package/src/app/api/uploads/[filename]/route.ts +1 -1
  63. package/src/app/api/uploads/route.ts +1 -1
  64. package/src/app/api/webhooks/[id]/history/route.ts +2 -2
  65. package/src/app/layout.tsx +9 -6
  66. package/src/app/protocols/page.tsx +71 -89
  67. package/src/app/tasks/page.tsx +32 -32
  68. package/src/cli/index.js +1 -0
  69. package/src/cli/spec.js +1 -0
  70. package/src/components/agents/agent-sheet.tsx +5 -5
  71. package/src/components/auth/setup-wizard/index.tsx +4 -4
  72. package/src/components/auth/setup-wizard/step-agents.tsx +1 -1
  73. package/src/components/auth/setup-wizard/step-connect.tsx +1 -1
  74. package/src/components/auth/setup-wizard/utils.ts +1 -1
  75. package/src/components/chatrooms/chatroom-sheet.tsx +16 -276
  76. package/src/components/connectors/connector-list.tsx +26 -40
  77. package/src/components/connectors/connector-sheet.tsx +95 -149
  78. package/src/components/gateways/gateway-sheet.tsx +61 -110
  79. package/src/components/layout/live-query-sync.tsx +121 -0
  80. package/src/components/protocols/structured-session-launcher.tsx +24 -45
  81. package/src/components/providers/app-query-provider.tsx +17 -0
  82. package/src/components/providers/provider-list.tsx +60 -61
  83. package/src/components/providers/provider-sheet.tsx +74 -56
  84. package/src/components/skills/skill-list.tsx +5 -18
  85. package/src/components/skills/skill-sheet.tsx +21 -20
  86. package/src/components/skills/skills-workspace.tsx +48 -87
  87. package/src/components/tasks/task-card.tsx +20 -13
  88. package/src/components/tasks/task-column.tsx +22 -7
  89. package/src/components/tasks/task-list.tsx +8 -11
  90. package/src/components/tasks/task-sheet.tsx +111 -103
  91. package/src/features/agents/queries.ts +20 -0
  92. package/src/features/chatrooms/queries.ts +20 -0
  93. package/src/features/chats/queries.ts +27 -0
  94. package/src/features/connectors/queries.ts +145 -0
  95. package/src/features/credentials/queries.ts +37 -0
  96. package/src/features/extensions/queries.ts +26 -0
  97. package/src/features/external-agents/queries.ts +36 -0
  98. package/src/features/gateways/queries.ts +274 -0
  99. package/src/features/missions/queries.ts +23 -0
  100. package/src/features/projects/queries.ts +20 -0
  101. package/src/features/protocols/queries.ts +149 -0
  102. package/src/features/providers/queries.ts +142 -0
  103. package/src/features/settings/queries.ts +20 -0
  104. package/src/features/skills/queries.ts +182 -0
  105. package/src/features/tasks/queries.ts +189 -0
  106. package/src/hooks/use-ws.ts +3 -2
  107. package/src/lib/app/api-client.ts +2 -2
  108. package/src/lib/query/client.ts +17 -0
  109. package/src/lib/server/agents/agent-runtime-config.ts +1 -1
  110. package/src/lib/server/agents/agent-service.ts +429 -0
  111. package/src/lib/server/agents/agent-thread-session.ts +6 -5
  112. package/src/lib/server/agents/autonomy-contract.ts +1 -4
  113. package/src/lib/server/agents/delegation-advisory.test.ts +206 -0
  114. package/src/lib/server/agents/delegation-advisory.ts +251 -0
  115. package/src/lib/server/agents/main-agent-loop.ts +98 -40
  116. package/src/lib/server/agents/subagent-runtime.ts +12 -0
  117. package/src/lib/server/autonomy/supervisor-reflection.test.ts +20 -1
  118. package/src/lib/server/autonomy/supervisor-reflection.ts +39 -19
  119. package/src/lib/server/build-llm.ts +7 -15
  120. package/src/lib/server/capability-router.test.ts +70 -1
  121. package/src/lib/server/capability-router.ts +24 -99
  122. package/src/lib/server/chat-execution/chat-execution-utils.ts +0 -15
  123. package/src/lib/server/chat-execution/chat-streaming-utils.ts +2 -4
  124. package/src/lib/server/chat-execution/chat-turn-finalization.ts +77 -12
  125. package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +4 -4
  126. package/src/lib/server/chat-execution/chat-turn-preflight.ts +2 -2
  127. package/src/lib/server/chat-execution/chat-turn-preparation.ts +41 -17
  128. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -2
  129. package/src/lib/server/chat-execution/chat-turn-tool-routing.test.ts +45 -0
  130. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +48 -17
  131. package/src/lib/server/chat-execution/continuation-evaluator.ts +4 -1
  132. package/src/lib/server/chat-execution/direct-memory-intent.test.ts +9 -0
  133. package/src/lib/server/chat-execution/direct-memory-intent.ts +12 -2
  134. package/src/lib/server/chat-execution/message-classifier.test.ts +35 -23
  135. package/src/lib/server/chat-execution/message-classifier.ts +74 -32
  136. package/src/lib/server/chat-execution/prompt-builder.test.ts +29 -0
  137. package/src/lib/server/chat-execution/prompt-builder.ts +37 -2
  138. package/src/lib/server/chat-execution/prompt-sections.test.ts +56 -0
  139. package/src/lib/server/chat-execution/prompt-sections.ts +193 -0
  140. package/src/lib/server/chat-execution/stream-agent-chat.ts +63 -7
  141. package/src/lib/server/chat-execution/stream-continuation.test.ts +36 -0
  142. package/src/lib/server/chat-execution/stream-continuation.ts +28 -13
  143. package/src/lib/server/chatrooms/chatroom-agent-signals.ts +26 -18
  144. package/src/lib/server/chatrooms/chatroom-helpers.ts +19 -18
  145. package/src/lib/server/chatrooms/chatroom-repository.ts +16 -0
  146. package/src/lib/server/chatrooms/chatroom-routing.test.ts +96 -0
  147. package/src/lib/server/chatrooms/chatroom-routing.ts +207 -53
  148. package/src/lib/server/chatrooms/mailbox-utils.ts +4 -2
  149. package/src/lib/server/chatrooms/session-mailbox.ts +50 -40
  150. package/src/lib/server/chats/chat-session-service.ts +410 -0
  151. package/src/lib/server/connectors/access.ts +1 -1
  152. package/src/lib/server/connectors/commands.ts +7 -6
  153. package/src/lib/server/connectors/connector-inbound.ts +14 -7
  154. package/src/lib/server/connectors/connector-outbound.ts +16 -11
  155. package/src/lib/server/connectors/connector-service.ts +453 -0
  156. package/src/lib/server/connectors/delivery.ts +17 -12
  157. package/src/lib/server/connectors/inbound-audio-transcription.ts +5 -14
  158. package/src/lib/server/connectors/media.ts +1 -1
  159. package/src/lib/server/connectors/response-media.ts +1 -1
  160. package/src/lib/server/connectors/session-consolidation.ts +11 -7
  161. package/src/lib/server/connectors/session.ts +9 -7
  162. package/src/lib/server/connectors/voice-note.ts +2 -1
  163. package/src/lib/server/context-manager.ts +20 -1
  164. package/src/lib/server/cost.ts +2 -3
  165. package/src/lib/server/credentials/credential-repository.ts +43 -4
  166. package/src/lib/server/credentials/credential-service.ts +112 -0
  167. package/src/lib/server/daemon/admin-metadata.ts +64 -0
  168. package/src/lib/server/daemon/controller.ts +577 -0
  169. package/src/lib/server/daemon/daemon-runtime.ts +352 -0
  170. package/src/lib/server/daemon/daemon-status-repository.ts +63 -0
  171. package/src/lib/server/daemon/types.ts +101 -0
  172. package/src/lib/server/embeddings.ts +3 -9
  173. package/src/lib/server/eval/agent-regression.ts +3 -2
  174. package/src/lib/server/eval/runner.ts +2 -2
  175. package/src/lib/server/execution-brief.test.ts +167 -0
  176. package/src/lib/server/execution-brief.ts +295 -0
  177. package/src/lib/server/execution-engine/chat-turn.ts +9 -0
  178. package/src/lib/server/execution-engine/import-boundary.test.ts +44 -0
  179. package/src/lib/server/execution-engine/index.ts +35 -0
  180. package/src/lib/server/execution-engine/task-attempt.ts +303 -0
  181. package/src/lib/server/execution-engine/types.ts +33 -0
  182. package/src/lib/server/gateways/gateway-profile-repository.ts +47 -3
  183. package/src/lib/server/gateways/gateway-profile-service.ts +200 -0
  184. package/src/lib/server/memory/session-archive-memory.ts +12 -10
  185. package/src/lib/server/messages/message-repository.ts +330 -0
  186. package/src/lib/server/missions/mission-service/core.ts +8 -6
  187. package/src/lib/server/openclaw/agent-resolver.ts +2 -3
  188. package/src/lib/server/openclaw/doctor.ts +1 -1
  189. package/src/lib/server/openclaw/gateway.test.ts +10 -1
  190. package/src/lib/server/openclaw/gateway.ts +5 -14
  191. package/src/lib/server/openclaw/health.ts +3 -11
  192. package/src/lib/server/openclaw/sync.ts +8 -6
  193. package/src/lib/server/persistence/storage-context.ts +3 -0
  194. package/src/lib/server/protocols/protocol-agent-turn.ts +25 -17
  195. package/src/lib/server/protocols/protocol-normalization.ts +1 -1
  196. package/src/lib/server/protocols/protocol-queries.ts +13 -7
  197. package/src/lib/server/protocols/protocol-run-lifecycle.ts +16 -20
  198. package/src/lib/server/protocols/protocol-run-repository.ts +81 -0
  199. package/src/lib/server/protocols/protocol-step-processors.ts +23 -31
  200. package/src/lib/server/protocols/protocol-swarm.ts +8 -8
  201. package/src/lib/server/protocols/protocol-template-repository.ts +42 -0
  202. package/src/lib/server/protocols/protocol-templates.ts +4 -2
  203. package/src/lib/server/protocols/protocol-types.ts +10 -7
  204. package/src/lib/server/provider-endpoint.ts +7 -12
  205. package/src/lib/server/provider-model-discovery.ts +2 -11
  206. package/src/lib/server/query-expansion.ts +5 -6
  207. package/src/lib/server/run-context.test.ts +365 -0
  208. package/src/lib/server/run-context.ts +367 -0
  209. package/src/lib/server/runtime/heartbeat-service.ts +7 -5
  210. package/src/lib/server/runtime/queue/core.ts +61 -190
  211. package/src/lib/server/runtime/run-ledger.ts +8 -0
  212. package/src/lib/server/runtime/session-run-manager/drain.ts +2 -2
  213. package/src/lib/server/runtime/session-run-manager/enqueue.ts +6 -0
  214. package/src/lib/server/runtime/session-run-manager/state.ts +4 -0
  215. package/src/lib/server/schedules/schedule-route-service.ts +230 -0
  216. package/src/lib/server/service-result.ts +16 -0
  217. package/src/lib/server/session-note.ts +2 -3
  218. package/src/lib/server/session-reset-policy.ts +4 -3
  219. package/src/lib/server/session-tools/connector.ts +9 -6
  220. package/src/lib/server/session-tools/context-mgmt.ts +58 -9
  221. package/src/lib/server/session-tools/crud.ts +162 -10
  222. package/src/lib/server/session-tools/delegate.ts +1 -1
  223. package/src/lib/server/session-tools/manage-tasks.test.ts +152 -0
  224. package/src/lib/server/session-tools/memory.ts +6 -4
  225. package/src/lib/server/session-tools/session-info.test.ts +56 -0
  226. package/src/lib/server/session-tools/session-info.ts +119 -12
  227. package/src/lib/server/session-tools/skill-runtime.ts +3 -1
  228. package/src/lib/server/session-tools/skills.ts +15 -15
  229. package/src/lib/server/session-tools/subagent.test.ts +115 -1
  230. package/src/lib/server/session-tools/subagent.ts +125 -7
  231. package/src/lib/server/session-tools/team-context.ts +4 -3
  232. package/src/lib/server/session-tools/wallet.ts +0 -58
  233. package/src/lib/server/sessions/session-lineage.ts +55 -0
  234. package/src/lib/server/sessions/session-repository.ts +2 -2
  235. package/src/lib/server/skills/learned-skills.ts +24 -23
  236. package/src/lib/server/skills/runtime-skill-resolver.ts +2 -1
  237. package/src/lib/server/skills/skill-repository.ts +136 -13
  238. package/src/lib/server/skills/skill-suggestions.ts +25 -28
  239. package/src/lib/server/storage-normalization.test.ts +44 -267
  240. package/src/lib/server/storage-normalization.ts +75 -0
  241. package/src/lib/server/storage.ts +19 -0
  242. package/src/lib/server/structured-extract.ts +3 -14
  243. package/src/lib/server/tasks/task-followups.ts +16 -11
  244. package/src/lib/server/tasks/task-result.test.ts +25 -29
  245. package/src/lib/server/tasks/task-result.ts +5 -9
  246. package/src/lib/server/tasks/task-route-service.ts +449 -0
  247. package/src/lib/server/text-normalization.ts +41 -0
  248. package/src/lib/server/tool-planning.ts +6 -42
  249. package/src/lib/server/upload-path.ts +5 -0
  250. package/src/lib/server/working-state/extraction.ts +614 -0
  251. package/src/lib/server/working-state/normalization.ts +866 -0
  252. package/src/lib/server/working-state/prompt.ts +60 -0
  253. package/src/lib/server/working-state/repository.ts +38 -0
  254. package/src/lib/server/working-state/service.test.ts +253 -0
  255. package/src/lib/server/working-state/service.ts +293 -0
  256. package/src/lib/validation/schemas.ts +1 -0
  257. package/src/lib/ws-client.ts +3 -3
  258. package/src/stores/slices/task-slice.ts +1 -4
  259. package/src/stores/use-chatroom-store.ts +2 -2
  260. package/src/types/index.ts +277 -12
@@ -3,11 +3,14 @@ import path from 'node:path'
3
3
  import type { BoardTask, Connector, MessageToolEvent } from '@/types'
4
4
  import { normalizeWhatsappTarget } from '@/lib/server/connectors/response-media'
5
5
  import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
6
+ import { loadConnectors } from '@/lib/server/connectors/connector-repository'
6
7
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
7
- import { loadConnectors, loadSessions, UPLOAD_DIR } from '@/lib/server/storage'
8
+ import { loadSessions } from '@/lib/server/sessions/session-repository'
9
+ import { UPLOAD_DIR } from '@/lib/server/upload-path'
8
10
  import { errorMessage } from '@/lib/shared-utils'
9
11
  import { isMainSession } from '@/lib/server/agents/main-agent-loop'
10
12
  import { log } from '@/lib/server/logger'
13
+ import { getMessages } from '@/lib/server/messages/message-repository'
11
14
 
12
15
  const TAG = 'task-followups'
13
16
 
@@ -291,9 +294,10 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
291
294
  const ownerSessionTarget = resolveMainSessionOwnerTarget()
292
295
  if (ownerSessionTarget) return ownerSessionTarget
293
296
 
294
- if (!isMainSession(sourceSession) && Array.isArray(sourceSession.messages)) {
295
- for (let index = sourceSession.messages.length - 1; index >= 0; index -= 1) {
296
- const message = sourceSession.messages[index]
297
+ const sourceMessages = typeof sourceSession.id === 'string' ? getMessages(sourceSession.id) : []
298
+ if (!isMainSession(sourceSession) && sourceMessages.length > 0) {
299
+ for (let index = sourceMessages.length - 1; index >= 0; index -= 1) {
300
+ const message = sourceMessages[index]
297
301
  if (!message || message.role !== 'user') continue
298
302
  if (message.historyExcluded === true) continue
299
303
 
@@ -408,14 +412,15 @@ export function taskAlreadyDeliveredToConnectorTarget(params: {
408
412
  : ''
409
413
  if (!taskSessionId) return false
410
414
  const session = params.sessions[taskSessionId]
411
- if (!session || !Array.isArray(session.messages)) return false
415
+ if (!session) return false
412
416
 
417
+ const sessionMessages = typeof session.id === 'string' ? getMessages(session.id) : []
413
418
  const connector = params.connectors[params.target.connectorId]
414
419
  const normalizedTargetChannel = normalizeFollowupChannelForConnector(connector, params.target.channelId)
415
420
  if (!normalizedTargetChannel) return false
416
421
 
417
- for (let index = session.messages.length - 1; index >= 0; index -= 1) {
418
- const message = session.messages[index]
422
+ for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
423
+ const message = sessionMessages[index]
419
424
  if (!message || message.role !== 'assistant' || !Array.isArray(message.toolEvents)) continue
420
425
  for (const event of message.toolEvents) {
421
426
  const delivered = extractDeliveredConnectorTarget(event as MessageToolEvent)
@@ -446,14 +451,14 @@ export async function notifyConnectorTaskFollowups(params: {
446
451
  const targets = collectTaskConnectorFollowupTargets({
447
452
  task,
448
453
  sessions: sessions as Record<string, SessionLike>,
449
- connectors: connectors as any,
454
+ connectors,
450
455
  running: running as RunningConnectorLike[],
451
456
  })
452
457
  if (!targets.length) return
453
458
  const originTarget = resolveTaskOriginConnectorFollowupTarget({
454
459
  task,
455
460
  sessions: sessions as Record<string, SessionLike>,
456
- connectors: connectors as any,
461
+ connectors,
457
462
  running: running as RunningConnectorLike[],
458
463
  })
459
464
  const preferredTargetKey = originTarget
@@ -473,8 +478,8 @@ export async function notifyConnectorTaskFollowups(params: {
473
478
  continue
474
479
  }
475
480
 
476
- const template = typeof (connector as any).config?.taskFollowupTemplate === 'string'
477
- ? (connector as any).config.taskFollowupTemplate.trim()
481
+ const template = typeof connector.config?.taskFollowupTemplate === 'string'
482
+ ? connector.config.taskFollowupTemplate.trim()
478
483
  : ''
479
484
  const message = template
480
485
  ? fillTaskFollowupTemplate(template, {
@@ -4,41 +4,37 @@ import { extractTaskResult } from '@/lib/server/tasks/task-result'
4
4
 
5
5
  describe('extractTaskResult', () => {
6
6
  it('limits artifact extraction to messages from the current run window', () => {
7
- const session = {
8
- messages: [
9
- {
10
- role: 'assistant',
11
- time: 1_000,
12
- text: 'old run artifact: /api/uploads/wiki-old.png',
13
- },
14
- {
15
- role: 'assistant',
16
- time: 2_000,
17
- text: 'new run artifact: /api/uploads/wiki-new.png',
18
- },
19
- ],
20
- }
7
+ const messages = [
8
+ {
9
+ role: 'assistant',
10
+ time: 1_000,
11
+ text: 'old run artifact: /api/uploads/wiki-old.png',
12
+ },
13
+ {
14
+ role: 'assistant',
15
+ time: 2_000,
16
+ text: 'new run artifact: /api/uploads/wiki-new.png',
17
+ },
18
+ ]
21
19
 
22
- const result = extractTaskResult(session, 'done', { sinceTime: 1_500 })
20
+ const result = extractTaskResult(messages, 'done', { sinceTime: 1_500 })
23
21
  assert.deepEqual(result.artifacts.map((a) => a.url), ['/api/uploads/wiki-new.png'])
24
22
  })
25
23
 
26
24
  it('excludes messages without timestamps when sinceTime is provided', () => {
27
- const session = {
28
- messages: [
29
- {
30
- role: 'assistant',
31
- text: 'undated artifact: /api/uploads/undated.png',
32
- },
33
- {
34
- role: 'assistant',
35
- time: 5_000,
36
- text: 'dated artifact: /api/uploads/dated.png',
37
- },
38
- ],
39
- }
25
+ const messages = [
26
+ {
27
+ role: 'assistant',
28
+ text: 'undated artifact: /api/uploads/undated.png',
29
+ },
30
+ {
31
+ role: 'assistant',
32
+ time: 5_000,
33
+ text: 'dated artifact: /api/uploads/dated.png',
34
+ },
35
+ ]
40
36
 
41
- const result = extractTaskResult(session, 'done', { sinceTime: 4_000 })
37
+ const result = extractTaskResult(messages, 'done', { sinceTime: 4_000 })
42
38
  assert.deepEqual(result.artifacts.map((a) => a.url), ['/api/uploads/dated.png'])
43
39
  })
44
40
  })
@@ -49,10 +49,6 @@ interface MessageLike {
49
49
  toolEvents?: Array<{ name?: string; output?: string }>
50
50
  }
51
51
 
52
- interface SessionLike {
53
- messages?: MessageLike[]
54
- }
55
-
56
52
  interface ExtractTaskResultOptions {
57
53
  sinceTime?: number | null
58
54
  }
@@ -62,12 +58,12 @@ interface ExtractTaskResultOptions {
62
58
  // ---------------------------------------------------------------------------
63
59
 
64
60
  /**
65
- * Walk a session's messages and extract all artifacts + a clean summary.
61
+ * Walk messages and extract all artifacts + a clean summary.
66
62
  * Replaces the old regex-based `extractLatestUploadUrl` and
67
63
  * `summarizeScheduleTaskResult` with a single Zod-validated pass.
68
64
  */
69
65
  export function extractTaskResult(
70
- session: SessionLike | null | undefined,
66
+ messages: MessageLike[] | null | undefined,
71
67
  rawResultText: string | null | undefined,
72
68
  options?: ExtractTaskResultOptions,
73
69
  ): TaskResult {
@@ -85,9 +81,9 @@ export function extractTaskResult(
85
81
  artifacts.push({ url, type: classifyArtifact(filename), filename })
86
82
  }
87
83
 
88
- // Walk session messages to collect all artifact URLs
89
- if (Array.isArray(session?.messages)) {
90
- for (const msg of session.messages) {
84
+ // Walk messages to collect all artifact URLs
85
+ if (Array.isArray(messages)) {
86
+ for (const msg of messages) {
91
87
  if (sinceTime !== null) {
92
88
  const msgTime = typeof msg.time === 'number' && Number.isFinite(msg.time) ? msg.time : null
93
89
  if (msgTime === null || msgTime < sinceTime) continue
@@ -0,0 +1,449 @@
1
+ import { genId } from '@/lib/id'
2
+ import { getEnabledCapabilityIds } from '@/lib/capability-selection'
3
+ import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-loop'
4
+ import { loadAgents } from '@/lib/server/agents/agent-repository'
5
+ import { logActivity } from '@/lib/server/activity/activity-log'
6
+ import { createNotification } from '@/lib/server/create-notification'
7
+ import { validateDag, cascadeUnblock } from '@/lib/server/dag-validation'
8
+ import { getExtensionManager } from '@/lib/server/extensions'
9
+ import {
10
+ enrichTaskWithMissionSummary,
11
+ ensureMissionForTask,
12
+ noteMissionTaskFinished,
13
+ } from '@/lib/server/missions/mission-service'
14
+ import {
15
+ disableSessionHeartbeat,
16
+ enqueueTask,
17
+ recoverStalledRunningTasks,
18
+ validateCompletedTasksQueue,
19
+ } from '@/lib/server/runtime/queue'
20
+ import { dispatchWake } from '@/lib/server/runtime/wake-dispatcher'
21
+ import { serviceFail, serviceOk } from '@/lib/server/service-result'
22
+ import { loadSettings } from '@/lib/server/settings/settings-repository'
23
+ import {
24
+ deleteTask,
25
+ loadTask,
26
+ loadTasks,
27
+ saveTask,
28
+ saveTaskMany,
29
+ } from '@/lib/server/tasks/task-repository'
30
+ import { resolveTaskAgentFromDescription } from '@/lib/server/tasks/task-mention'
31
+ import { applyTaskPatch, prepareTaskCreation } from '@/lib/server/tasks/task-service'
32
+ import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
33
+ import { notify } from '@/lib/server/ws-hub'
34
+ import type { BoardTask, BoardTaskStatus, TaskComment } from '@/types'
35
+ import type { ServiceResult } from '@/lib/server/service-result'
36
+
37
+ import '@/lib/server/builtin-extensions'
38
+
39
+ const VALID_BULK_STATUSES: BoardTaskStatus[] = ['backlog', 'queued', 'running', 'completed', 'failed', 'archived']
40
+
41
+ function normalizeTaskCommentInput(value: unknown): TaskComment | null {
42
+ if (typeof value === 'string' && value.trim()) {
43
+ return {
44
+ id: genId(),
45
+ author: 'user',
46
+ text: value.trim(),
47
+ createdAt: Date.now(),
48
+ }
49
+ }
50
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
51
+ const row = value as Record<string, unknown>
52
+ const text = typeof row.text === 'string' ? row.text.trim() : ''
53
+ if (!text) return null
54
+ return {
55
+ id: typeof row.id === 'string' && row.id.trim() ? row.id.trim() : genId(),
56
+ author: typeof row.author === 'string' && row.author.trim() ? row.author.trim() : 'user',
57
+ agentId: typeof row.agentId === 'string' && row.agentId.trim() ? row.agentId.trim() : undefined,
58
+ text,
59
+ createdAt: typeof row.createdAt === 'number' && Number.isFinite(row.createdAt) ? row.createdAt : Date.now(),
60
+ }
61
+ }
62
+
63
+ export function prepareTasksForListing() {
64
+ validateCompletedTasksQueue()
65
+ recoverStalledRunningTasks()
66
+ const allTasks = loadTasks()
67
+ return Object.fromEntries(
68
+ Object.entries(allTasks).map(([id, task]) => [id, enrichTaskWithMissionSummary(task)]),
69
+ )
70
+ }
71
+
72
+ export function updateTaskFromRoute(id: string, body: Record<string, unknown>): ServiceResult<BoardTask> {
73
+ const settings = loadSettings()
74
+ const tasks = loadTasks()
75
+ if (!tasks[id]) return serviceFail(404, 'Task not found')
76
+
77
+ const prevStatus = tasks[id].status
78
+ if (Array.isArray(body.blockedBy)) {
79
+ const dagResult = validateDag(tasks, id, body.blockedBy)
80
+ if (!dagResult.valid) {
81
+ return serviceFail(400, 'Dependency cycle detected')
82
+ }
83
+ }
84
+
85
+ if (body.appendComment) {
86
+ const appendedComment = normalizeTaskCommentInput(body.appendComment)
87
+ if (!appendedComment) {
88
+ return serviceFail(400, 'Invalid task comment payload')
89
+ }
90
+ if (!tasks[id].comments) tasks[id].comments = []
91
+ tasks[id].comments.push(appendedComment)
92
+ tasks[id].updatedAt = Date.now()
93
+ } else {
94
+ applyTaskPatch({
95
+ task: tasks[id],
96
+ patch: body,
97
+ now: Date.now(),
98
+ settings,
99
+ preserveCompletedAt: true,
100
+ clearProjectIdWhenNull: true,
101
+ invalidCompletionCommentAuthor: 'System',
102
+ })
103
+ }
104
+ tasks[id].id = id
105
+
106
+ if (typeof body.parentTaskId === 'string' || body.parentTaskId === null) {
107
+ const oldParentId = tasks[id].parentTaskId
108
+ const newParentId = typeof body.parentTaskId === 'string' && body.parentTaskId.trim() ? body.parentTaskId.trim() : null
109
+ if (oldParentId && oldParentId !== newParentId && tasks[oldParentId]) {
110
+ const oldSubs = Array.isArray(tasks[oldParentId].subtaskIds) ? tasks[oldParentId].subtaskIds : []
111
+ tasks[oldParentId].subtaskIds = oldSubs.filter((s: string) => s !== id)
112
+ tasks[oldParentId].updatedAt = Date.now()
113
+ saveTask(oldParentId, tasks[oldParentId])
114
+ }
115
+ if (newParentId && tasks[newParentId]) {
116
+ const newSubs = Array.isArray(tasks[newParentId].subtaskIds) ? tasks[newParentId].subtaskIds : []
117
+ if (!newSubs.includes(id)) {
118
+ tasks[newParentId].subtaskIds = [...newSubs, id]
119
+ tasks[newParentId].updatedAt = Date.now()
120
+ saveTask(newParentId, tasks[newParentId])
121
+ }
122
+ }
123
+ tasks[id].parentTaskId = newParentId
124
+ }
125
+
126
+ if (prevStatus !== 'archived' && tasks[id].status === 'archived') {
127
+ tasks[id].archivedAt = Date.now()
128
+ }
129
+
130
+ saveTask(id, tasks[id])
131
+ const mission = ensureMissionForTask(tasks[id], { source: 'manual' })
132
+ if (tasks[id].status === 'completed' || tasks[id].status === 'failed' || tasks[id].status === 'cancelled') {
133
+ noteMissionTaskFinished(tasks[id], tasks[id].status, tasks[id].id)
134
+ }
135
+ logActivity({ entityType: 'task', entityId: id, action: 'updated', actor: 'user', summary: `Task updated: "${tasks[id].title}" (${prevStatus} → ${tasks[id].status})` })
136
+ if (prevStatus !== tasks[id].status) {
137
+ pushMainLoopEventToMainSessions({
138
+ type: 'task_status_changed',
139
+ text: `Task "${tasks[id].title}" (${id}) moved ${prevStatus} → ${tasks[id].status}.`,
140
+ })
141
+ }
142
+
143
+ if (prevStatus !== tasks[id].status && tasks[id].status === 'cancelled') {
144
+ disableSessionHeartbeat(tasks[id].sessionId)
145
+ notify('tasks')
146
+ return serviceOk(enrichTaskWithMissionSummary({
147
+ ...tasks[id],
148
+ missionId: mission?.id || tasks[id].missionId || null,
149
+ }))
150
+ }
151
+
152
+ if (prevStatus !== tasks[id].status && (tasks[id].status === 'completed' || tasks[id].status === 'failed')) {
153
+ disableSessionHeartbeat(tasks[id].sessionId)
154
+ createNotification({
155
+ type: tasks[id].status === 'completed' ? 'success' : 'error',
156
+ title: `Task ${tasks[id].status}: "${tasks[id].title}"`,
157
+ message: tasks[id].status === 'failed' ? tasks[id].error?.slice(0, 200) : undefined,
158
+ entityType: 'task',
159
+ entityId: id,
160
+ })
161
+
162
+ if (tasks[id].status === 'completed') {
163
+ const agentExtensions = tasks[id].agentId ? getEnabledCapabilityIds(loadAgents()[tasks[id].agentId]) : []
164
+ getExtensionManager().runHook(
165
+ 'onTaskComplete',
166
+ { taskId: id, result: tasks[id].result },
167
+ { enabledIds: agentExtensions },
168
+ )
169
+ }
170
+
171
+ if (tasks[id].sessionId) {
172
+ enqueueSystemEvent(tasks[id].sessionId, `Task ${tasks[id].status}: ${tasks[id].title}`)
173
+ }
174
+ if (tasks[id].agentId) {
175
+ dispatchWake({
176
+ mode: 'immediate',
177
+ agentId: tasks[id].agentId,
178
+ sessionId: tasks[id].sessionId || undefined,
179
+ eventId: `task:${id}:${tasks[id].status}`,
180
+ reason: 'task-completed',
181
+ source: `task:${id}`,
182
+ resumeMessage: `Task ${tasks[id].status}: ${tasks[id].title}`,
183
+ detail: tasks[id].status === 'failed'
184
+ ? String(tasks[id].error || '').slice(0, 400)
185
+ : JSON.stringify(tasks[id].result || '').slice(0, 400),
186
+ })
187
+ }
188
+ }
189
+
190
+ if (tasks[id].status === 'queued') {
191
+ const blockers = Array.isArray(tasks[id].blockedBy) ? tasks[id].blockedBy : []
192
+ const incompleteBlocker = blockers.find((bid: string) => tasks[bid] && tasks[bid].status !== 'completed')
193
+ if (incompleteBlocker) {
194
+ tasks[id].status = prevStatus
195
+ tasks[id].updatedAt = Date.now()
196
+ saveTask(id, tasks[id])
197
+ return serviceFail(409, 'Cannot queue: blocked by incomplete tasks')
198
+ }
199
+ }
200
+
201
+ if (tasks[id].status === 'completed') {
202
+ const unblockedIds = cascadeUnblock(tasks, id)
203
+ if (unblockedIds.length > 0) {
204
+ saveTaskMany([
205
+ [id, tasks[id]],
206
+ ...unblockedIds.map((uid) => [uid, tasks[uid]] as [string, BoardTask]),
207
+ ])
208
+ for (const uid of unblockedIds) {
209
+ enqueueTask(uid)
210
+ }
211
+ }
212
+ }
213
+
214
+ if (prevStatus !== 'queued' && tasks[id].status === 'queued') {
215
+ enqueueTask(id)
216
+ }
217
+
218
+ notify('tasks')
219
+ return serviceOk(enrichTaskWithMissionSummary({
220
+ ...tasks[id],
221
+ missionId: mission?.id || tasks[id].missionId || null,
222
+ }))
223
+ }
224
+
225
+ export function archiveTaskFromRoute(id: string): ServiceResult<BoardTask> {
226
+ const task = loadTask(id)
227
+ if (!task) return serviceFail(404, 'Task not found')
228
+ task.status = 'archived'
229
+ task.archivedAt = Date.now()
230
+ task.updatedAt = Date.now()
231
+ saveTask(id, task)
232
+ logActivity({ entityType: 'task', entityId: id, action: 'deleted', actor: 'user', summary: `Task archived: "${task.title}"` })
233
+ pushMainLoopEventToMainSessions({
234
+ type: 'task_archived',
235
+ text: `Task archived: "${task.title}" (${id}).`,
236
+ })
237
+ notify('tasks')
238
+ return serviceOk(task)
239
+ }
240
+
241
+ export function createTaskFromRoute(body: Record<string, unknown>): ServiceResult<BoardTask> {
242
+ const id = genId()
243
+ const now = Date.now()
244
+ const tasks = loadTasks()
245
+ const settings = loadSettings()
246
+ const maxAttempts = Number.isFinite(Number(body.maxAttempts))
247
+ ? Math.max(1, Math.min(20, Math.trunc(Number(body.maxAttempts))))
248
+ : Math.max(1, Math.min(20, Math.trunc(Number(settings.defaultTaskMaxAttempts ?? 3))))
249
+ const retryBackoffSec = Number.isFinite(Number(body.retryBackoffSec))
250
+ ? Math.max(1, Math.min(3600, Math.trunc(Number(body.retryBackoffSec))))
251
+ : Math.max(1, Math.min(3600, Math.trunc(Number(settings.taskRetryBackoffSec ?? 30))))
252
+ if (Array.isArray(body.blockedBy) && body.blockedBy.length > 0) {
253
+ const dagResult = validateDag(tasks, id, body.blockedBy)
254
+ if (!dagResult.valid) {
255
+ return serviceFail(400, 'Dependency cycle detected')
256
+ }
257
+ }
258
+ const description = typeof body.description === 'string' ? body.description : ''
259
+ const resolvedAgentId = description
260
+ ? resolveTaskAgentFromDescription(description, (body.agentId as string) || '', loadAgents())
261
+ : ((body.agentId as string) || '')
262
+
263
+ const prepared = prepareTaskCreation({
264
+ id,
265
+ input: {
266
+ ...body,
267
+ agentId: resolvedAgentId,
268
+ },
269
+ tasks,
270
+ now,
271
+ settings,
272
+ seed: {
273
+ projectId: typeof body.projectId === 'string' && body.projectId ? body.projectId : null,
274
+ goalContract: body.goalContract || null,
275
+ cwd: typeof body.cwd === 'string' ? body.cwd : null,
276
+ file: typeof body.file === 'string' ? body.file : null,
277
+ sessionId: typeof body.sessionId === 'string' ? body.sessionId : null,
278
+ result: typeof body.result === 'string' ? body.result : null,
279
+ error: typeof body.error === 'string' ? body.error : null,
280
+ outputFiles: Array.isArray(body.outputFiles)
281
+ ? body.outputFiles.filter((entry): entry is string => typeof entry === 'string').slice(0, 24)
282
+ : [],
283
+ artifacts: Array.isArray(body.artifacts)
284
+ ? body.artifacts
285
+ .filter((artifact) => artifact && typeof artifact === 'object')
286
+ .map((artifact) => {
287
+ const row = artifact as { url?: unknown; type?: unknown; filename?: unknown }
288
+ const normalizedType = String(row.type || '')
289
+ return {
290
+ url: String(row.url || ''),
291
+ type: ['image', 'video', 'pdf', 'file'].includes(normalizedType)
292
+ ? (normalizedType as 'image' | 'video' | 'pdf' | 'file')
293
+ : 'file',
294
+ filename: String(row.filename || ''),
295
+ }
296
+ })
297
+ .filter((artifact) => artifact.url && artifact.filename)
298
+ .slice(0, 24)
299
+ : [],
300
+ archivedAt: null,
301
+ attempts: 0,
302
+ maxAttempts,
303
+ retryBackoffSec,
304
+ retryScheduledAt: null,
305
+ deadLetteredAt: null,
306
+ checkpoint: null,
307
+ blockedBy: Array.isArray(body.blockedBy) ? body.blockedBy.filter((s): s is string => typeof s === 'string') : [],
308
+ blocks: Array.isArray(body.blocks) ? body.blocks.filter((s): s is string => typeof s === 'string') : [],
309
+ tags: Array.isArray(body.tags) ? body.tags.filter((s): s is string => typeof s === 'string') : [],
310
+ dueAt: typeof body.dueAt === 'number' ? body.dueAt : null,
311
+ customFields: body.customFields && typeof body.customFields === 'object' ? body.customFields : undefined,
312
+ priority: body.priority && ['low', 'medium', 'high', 'critical'].includes(String(body.priority))
313
+ ? body.priority as BoardTask['priority']
314
+ : undefined,
315
+ },
316
+ })
317
+ if (!prepared.ok) {
318
+ return serviceFail(400, prepared.error)
319
+ }
320
+ if (prepared.duplicate) {
321
+ return serviceOk({ ...prepared.duplicate, deduplicated: true } as BoardTask)
322
+ }
323
+
324
+ const task = prepared.task
325
+ if (task.status === 'completed') {
326
+ const agentExtensions = resolvedAgentId ? getEnabledCapabilityIds(loadAgents()[resolvedAgentId]) : []
327
+ getExtensionManager().runHook(
328
+ 'onTaskComplete',
329
+ { taskId: id, result: task.result },
330
+ { enabledIds: agentExtensions },
331
+ )
332
+ }
333
+
334
+ const parentTaskId = typeof body.parentTaskId === 'string' && body.parentTaskId.trim() ? body.parentTaskId.trim() : null
335
+ if (parentTaskId) {
336
+ task.parentTaskId = parentTaskId
337
+ const parentTask = tasks[parentTaskId]
338
+ if (parentTask) {
339
+ const subtaskIds = Array.isArray(parentTask.subtaskIds) ? parentTask.subtaskIds : []
340
+ if (!subtaskIds.includes(id)) {
341
+ parentTask.subtaskIds = [...subtaskIds, id]
342
+ parentTask.updatedAt = now
343
+ saveTask(parentTaskId, parentTask)
344
+ }
345
+ }
346
+ }
347
+
348
+ saveTask(id, task)
349
+ const mission = ensureMissionForTask(task, { source: 'manual' })
350
+ const finalTask = enrichTaskWithMissionSummary({
351
+ ...task,
352
+ missionId: mission?.id || task.missionId || null,
353
+ })
354
+ logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Task created: "${task.title}"` })
355
+ pushMainLoopEventToMainSessions({
356
+ type: 'task_created',
357
+ text: `Task created: "${task.title}" (${id}) with status ${task.status}.`,
358
+ })
359
+ if (task.status === 'queued') {
360
+ enqueueTask(id)
361
+ }
362
+ notify('tasks')
363
+ return serviceOk(finalTask)
364
+ }
365
+
366
+ export function bulkUpdateTasksFromRoute(body: Record<string, unknown>): ServiceResult<{ updated: number; ids: string[] }> {
367
+ const ids = body.ids
368
+ if (!Array.isArray(ids) || ids.length === 0) {
369
+ return serviceFail(400, 'ids must be a non-empty array')
370
+ }
371
+ const taskIds = ids.filter((id): id is string => typeof id === 'string')
372
+ if (taskIds.length === 0) {
373
+ return serviceFail(400, 'No valid task IDs provided')
374
+ }
375
+ const tasks = loadTasks()
376
+ let updated = 0
377
+ const results: string[] = []
378
+
379
+ for (const id of taskIds) {
380
+ if (!tasks[id]) continue
381
+ const prevStatus = tasks[id].status
382
+ if (typeof body.status === 'string' && VALID_BULK_STATUSES.includes(body.status as BoardTaskStatus)) {
383
+ tasks[id].status = body.status as BoardTaskStatus
384
+ if (body.status === 'archived' && prevStatus !== 'archived') {
385
+ tasks[id].archivedAt = Date.now()
386
+ }
387
+ }
388
+ if ('agentId' in body) {
389
+ tasks[id].agentId = body.agentId === null ? '' : String(body.agentId)
390
+ }
391
+ if ('projectId' in body) {
392
+ if (body.projectId === null) delete tasks[id].projectId
393
+ else tasks[id].projectId = String(body.projectId)
394
+ }
395
+ tasks[id].updatedAt = Date.now()
396
+ updated += 1
397
+ results.push(id)
398
+ if (prevStatus !== tasks[id].status) {
399
+ logActivity({
400
+ entityType: 'task',
401
+ entityId: id,
402
+ action: 'updated',
403
+ actor: 'user',
404
+ summary: `Bulk update: "${tasks[id].title}" (${prevStatus} → ${tasks[id].status})`,
405
+ })
406
+ pushMainLoopEventToMainSessions({
407
+ type: 'task_status_changed',
408
+ text: `Task "${tasks[id].title}" (${id}) moved ${prevStatus} → ${tasks[id].status}.`,
409
+ })
410
+ if (tasks[id].status === 'completed' || tasks[id].status === 'failed') {
411
+ disableSessionHeartbeat(tasks[id].sessionId)
412
+ }
413
+ if (prevStatus !== 'queued' && tasks[id].status === 'queued') {
414
+ enqueueTask(id)
415
+ }
416
+ }
417
+ }
418
+ saveTaskMany(results.map((id) => [id, tasks[id]] as [string, BoardTask]))
419
+ if (updated > 0) {
420
+ const action = body.status
421
+ ? `moved ${updated} task(s) to ${body.status}`
422
+ : `updated ${updated} task(s)`
423
+ createNotification({
424
+ type: 'success',
425
+ title: `Bulk update: ${action}`,
426
+ entityType: 'task',
427
+ })
428
+ }
429
+ notify('tasks')
430
+ return serviceOk({ updated, ids: results })
431
+ }
432
+
433
+ export function deleteTasksByFilter(filter: string | null) {
434
+ const tasks = loadTasks()
435
+ let removed = 0
436
+ const shouldRemove = (task: { status: string; sourceType?: string }) =>
437
+ filter === 'all'
438
+ || (filter === 'schedule' && task.sourceType === 'schedule')
439
+ || (filter === 'done' && (task.status === 'completed' || task.status === 'failed'))
440
+ || (!filter && task.status === 'archived')
441
+
442
+ for (const [id, task] of Object.entries(tasks)) {
443
+ if (!shouldRemove(task as { status: string; sourceType?: string })) continue
444
+ deleteTask(id)
445
+ removed += 1
446
+ }
447
+ notify('tasks')
448
+ return { removed, remaining: Object.keys(tasks).length - removed }
449
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared text-normalization utilities.
3
+ *
4
+ * Consolidates the cleanText / cleanMultiline / normalizeList helpers that
5
+ * were previously duplicated across 7+ server modules.
6
+ */
7
+
8
+ /** Collapse whitespace, trim, and cap at `max` characters. Returns `''` for non-string input. */
9
+ export function cleanText(value: unknown, max = 320): string {
10
+ if (typeof value !== 'string') return ''
11
+ return value.replace(/\s+/g, ' ').trim().slice(0, max)
12
+ }
13
+
14
+ /** Trim each line, drop blanks, rejoin, and cap at `max` characters. Returns `''` for non-string input. */
15
+ export function cleanMultiline(value: unknown, max = 1_200): string {
16
+ if (typeof value !== 'string') return ''
17
+ return value
18
+ .split('\n')
19
+ .map((line) => line.trim())
20
+ .filter(Boolean)
21
+ .join('\n')
22
+ .slice(0, max)
23
+ .trim()
24
+ }
25
+
26
+ /** Deduplicated, cleaned list of strings from unknown input. */
27
+ export function normalizeList(input: unknown, maxItems: number, maxChars = 240): string[] {
28
+ const values = Array.isArray(input) ? input : []
29
+ const seen = new Set<string>()
30
+ const out: string[] = []
31
+ for (const value of values) {
32
+ const cleaned = cleanText(value, maxChars)
33
+ if (!cleaned) continue
34
+ const key = cleaned.toLowerCase()
35
+ if (seen.has(key)) continue
36
+ seen.add(key)
37
+ out.push(cleaned)
38
+ if (out.length >= maxItems) break
39
+ }
40
+ return out
41
+ }