@swarmclawai/swarmclaw 0.7.1 → 0.7.3

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 (237) hide show
  1. package/README.md +155 -150
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +37 -9
  5. package/src/app/api/agents/route.ts +13 -2
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
  9. package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
  10. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  11. package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
  12. package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
  13. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  14. package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
  15. package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
  16. package/src/app/api/{sessions → chats}/route.ts +21 -7
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  18. package/src/app/api/connectors/doctor/route.ts +13 -0
  19. package/src/app/api/files/open/route.ts +16 -14
  20. package/src/app/api/memory/maintenance/route.ts +11 -1
  21. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  22. package/src/app/api/openclaw/skills/route.ts +11 -3
  23. package/src/app/api/plugins/dependencies/route.ts +24 -0
  24. package/src/app/api/plugins/install/route.ts +15 -92
  25. package/src/app/api/plugins/route.ts +6 -26
  26. package/src/app/api/plugins/settings/route.ts +40 -0
  27. package/src/app/api/plugins/ui/route.ts +1 -0
  28. package/src/app/api/settings/route.ts +49 -7
  29. package/src/app/api/tasks/[id]/route.ts +15 -6
  30. package/src/app/api/tasks/bulk/route.ts +2 -2
  31. package/src/app/api/tasks/route.ts +9 -4
  32. package/src/app/api/usage/route.ts +30 -0
  33. package/src/app/api/webhooks/[id]/route.ts +8 -1
  34. package/src/app/page.tsx +9 -2
  35. package/src/cli/index.js +39 -33
  36. package/src/cli/index.ts +43 -49
  37. package/src/cli/spec.js +29 -27
  38. package/src/components/agents/agent-card.tsx +16 -13
  39. package/src/components/agents/agent-chat-list.tsx +104 -4
  40. package/src/components/agents/agent-list.tsx +54 -22
  41. package/src/components/agents/agent-sheet.tsx +209 -18
  42. package/src/components/agents/cron-job-form.tsx +3 -3
  43. package/src/components/agents/inspector-panel.tsx +110 -50
  44. package/src/components/auth/access-key-gate.tsx +36 -97
  45. package/src/components/auth/setup-wizard.tsx +5 -38
  46. package/src/components/chat/chat-area.tsx +39 -27
  47. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
  48. package/src/components/chat/chat-header.tsx +299 -314
  49. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
  50. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  51. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  52. package/src/components/chat/message-bubble.tsx +4 -1
  53. package/src/components/chat/message-list.tsx +5 -3
  54. package/src/components/chat/session-debug-panel.tsx +1 -1
  55. package/src/components/chat/tool-request-banner.tsx +3 -3
  56. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  59. package/src/components/connectors/connector-list.tsx +265 -127
  60. package/src/components/connectors/connector-sheet.tsx +218 -1
  61. package/src/components/home/home-view.tsx +129 -5
  62. package/src/components/layout/app-layout.tsx +392 -182
  63. package/src/components/layout/mobile-header.tsx +26 -8
  64. package/src/components/plugins/plugin-list.tsx +487 -254
  65. package/src/components/plugins/plugin-sheet.tsx +236 -13
  66. package/src/components/projects/project-detail.tsx +183 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  68. package/src/components/shared/agent-picker-list.tsx +2 -2
  69. package/src/components/shared/command-palette.tsx +111 -25
  70. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  71. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +78 -1
  73. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  74. package/src/components/shared/settings/section-providers.tsx +1 -1
  75. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  76. package/src/components/shared/settings/section-secrets.tsx +6 -6
  77. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  78. package/src/components/shared/settings/section-voice.tsx +5 -1
  79. package/src/components/shared/settings/section-web-search.tsx +10 -2
  80. package/src/components/shared/settings/settings-page.tsx +244 -56
  81. package/src/components/tasks/approvals-panel.tsx +205 -18
  82. package/src/components/tasks/task-board.tsx +242 -46
  83. package/src/components/usage/metrics-dashboard.tsx +147 -1
  84. package/src/components/wallets/wallet-panel.tsx +17 -5
  85. package/src/components/webhooks/webhook-sheet.tsx +8 -8
  86. package/src/lib/auth.ts +17 -0
  87. package/src/lib/chat-streaming-state.test.ts +108 -0
  88. package/src/lib/chat-streaming-state.ts +108 -0
  89. package/src/lib/chat.ts +1 -1
  90. package/src/lib/{sessions.ts → chats.ts} +28 -18
  91. package/src/lib/openclaw-agent-id.test.ts +14 -0
  92. package/src/lib/openclaw-agent-id.ts +31 -0
  93. package/src/lib/providers/claude-cli.ts +1 -1
  94. package/src/lib/server/agent-assignment.test.ts +112 -0
  95. package/src/lib/server/agent-assignment.ts +169 -0
  96. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  97. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  98. package/src/lib/server/approvals.ts +483 -75
  99. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  100. package/src/lib/server/browser-state.test.ts +118 -0
  101. package/src/lib/server/browser-state.ts +123 -0
  102. package/src/lib/server/build-llm.test.ts +36 -0
  103. package/src/lib/server/build-llm.ts +11 -4
  104. package/src/lib/server/builtin-plugins.ts +34 -0
  105. package/src/lib/server/capability-router.ts +10 -8
  106. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  107. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  108. package/src/lib/server/chat-execution.ts +285 -165
  109. package/src/lib/server/chatroom-health.test.ts +26 -0
  110. package/src/lib/server/chatroom-health.ts +2 -3
  111. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  112. package/src/lib/server/chatroom-helpers.ts +48 -8
  113. package/src/lib/server/connectors/discord.ts +175 -11
  114. package/src/lib/server/connectors/doctor.test.ts +80 -0
  115. package/src/lib/server/connectors/doctor.ts +116 -0
  116. package/src/lib/server/connectors/manager.ts +948 -112
  117. package/src/lib/server/connectors/policy.test.ts +222 -0
  118. package/src/lib/server/connectors/policy.ts +452 -0
  119. package/src/lib/server/connectors/slack.ts +188 -9
  120. package/src/lib/server/connectors/telegram.ts +65 -15
  121. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  122. package/src/lib/server/connectors/thread-context.ts +72 -0
  123. package/src/lib/server/connectors/types.ts +41 -11
  124. package/src/lib/server/cost.ts +34 -1
  125. package/src/lib/server/daemon-state.ts +61 -3
  126. package/src/lib/server/data-dir.ts +13 -0
  127. package/src/lib/server/delegation-jobs.test.ts +140 -0
  128. package/src/lib/server/delegation-jobs.ts +248 -0
  129. package/src/lib/server/document-utils.test.ts +47 -0
  130. package/src/lib/server/document-utils.ts +397 -0
  131. package/src/lib/server/heartbeat-service.ts +14 -40
  132. package/src/lib/server/heartbeat-source.test.ts +22 -0
  133. package/src/lib/server/heartbeat-source.ts +7 -0
  134. package/src/lib/server/identity-continuity.test.ts +77 -0
  135. package/src/lib/server/identity-continuity.ts +127 -0
  136. package/src/lib/server/mailbox-utils.ts +347 -0
  137. package/src/lib/server/main-agent-loop.ts +28 -1103
  138. package/src/lib/server/memory-db.ts +4 -6
  139. package/src/lib/server/memory-tiers.ts +40 -0
  140. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  141. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  142. package/src/lib/server/openclaw-exec-config.ts +5 -6
  143. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  144. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  145. package/src/lib/server/openclaw-sync.ts +3 -2
  146. package/src/lib/server/orchestrator-lg.ts +20 -9
  147. package/src/lib/server/orchestrator.ts +7 -7
  148. package/src/lib/server/playwright-proxy.mjs +27 -3
  149. package/src/lib/server/plugins.test.ts +207 -0
  150. package/src/lib/server/plugins.ts +927 -66
  151. package/src/lib/server/provider-health.ts +38 -6
  152. package/src/lib/server/queue.ts +13 -28
  153. package/src/lib/server/scheduler.ts +2 -0
  154. package/src/lib/server/session-archive-memory.test.ts +85 -0
  155. package/src/lib/server/session-archive-memory.ts +230 -0
  156. package/src/lib/server/session-mailbox.ts +8 -18
  157. package/src/lib/server/session-reset-policy.test.ts +99 -0
  158. package/src/lib/server/session-reset-policy.ts +311 -0
  159. package/src/lib/server/session-run-manager.ts +33 -82
  160. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  161. package/src/lib/server/session-tools/calendar.ts +366 -0
  162. package/src/lib/server/session-tools/canvas.ts +1 -1
  163. package/src/lib/server/session-tools/chatroom.ts +4 -2
  164. package/src/lib/server/session-tools/connector.ts +114 -10
  165. package/src/lib/server/session-tools/context.ts +21 -5
  166. package/src/lib/server/session-tools/crawl.ts +447 -0
  167. package/src/lib/server/session-tools/crud.ts +74 -28
  168. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  169. package/src/lib/server/session-tools/delegate.ts +497 -24
  170. package/src/lib/server/session-tools/discovery.ts +24 -6
  171. package/src/lib/server/session-tools/document.ts +283 -0
  172. package/src/lib/server/session-tools/edit_file.ts +4 -2
  173. package/src/lib/server/session-tools/email.ts +320 -0
  174. package/src/lib/server/session-tools/extract.ts +137 -0
  175. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  176. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  177. package/src/lib/server/session-tools/file.ts +241 -25
  178. package/src/lib/server/session-tools/git.ts +1 -1
  179. package/src/lib/server/session-tools/http.ts +1 -1
  180. package/src/lib/server/session-tools/human-loop.ts +227 -0
  181. package/src/lib/server/session-tools/image-gen.ts +380 -0
  182. package/src/lib/server/session-tools/index.ts +130 -50
  183. package/src/lib/server/session-tools/mailbox.ts +276 -0
  184. package/src/lib/server/session-tools/memory.ts +172 -3
  185. package/src/lib/server/session-tools/monitor.ts +151 -8
  186. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  187. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  188. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  189. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  190. package/src/lib/server/session-tools/platform.ts +148 -7
  191. package/src/lib/server/session-tools/plugin-creator.ts +89 -26
  192. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  193. package/src/lib/server/session-tools/replicate.ts +301 -0
  194. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  195. package/src/lib/server/session-tools/sandbox.ts +4 -2
  196. package/src/lib/server/session-tools/schedule.ts +24 -12
  197. package/src/lib/server/session-tools/session-info.ts +43 -7
  198. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  199. package/src/lib/server/session-tools/shell.ts +5 -2
  200. package/src/lib/server/session-tools/subagent.ts +194 -28
  201. package/src/lib/server/session-tools/table.ts +587 -0
  202. package/src/lib/server/session-tools/wallet.ts +42 -12
  203. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  204. package/src/lib/server/session-tools/web.ts +926 -91
  205. package/src/lib/server/storage.ts +255 -16
  206. package/src/lib/server/stream-agent-chat.ts +116 -268
  207. package/src/lib/server/structured-extract.test.ts +72 -0
  208. package/src/lib/server/structured-extract.ts +373 -0
  209. package/src/lib/server/task-mention.test.ts +16 -2
  210. package/src/lib/server/task-mention.ts +61 -10
  211. package/src/lib/server/tool-aliases.ts +66 -18
  212. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  213. package/src/lib/server/tool-capability-policy.ts +38 -27
  214. package/src/lib/server/tool-retry.ts +2 -0
  215. package/src/lib/server/watch-jobs.test.ts +173 -0
  216. package/src/lib/server/watch-jobs.ts +532 -0
  217. package/src/lib/server/ws-hub.ts +5 -3
  218. package/src/lib/tool-definitions.ts +4 -0
  219. package/src/lib/validation/schemas.test.ts +26 -0
  220. package/src/lib/validation/schemas.ts +10 -1
  221. package/src/lib/ws-client.ts +14 -12
  222. package/src/proxy.ts +5 -5
  223. package/src/stores/use-app-store.ts +5 -11
  224. package/src/stores/use-chat-store.ts +38 -9
  225. package/src/types/index.ts +352 -47
  226. package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
  227. package/src/components/sessions/new-session-sheet.tsx +0 -253
  228. package/src/lib/server/main-session.ts +0 -24
  229. package/src/lib/server/session-run-manager.test.ts +0 -23
  230. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  231. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  232. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  233. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  234. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  235. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  236. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  237. /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
@@ -1,78 +1,6 @@
1
- import { genId } from '@/lib/id'
2
- import { z } from 'zod'
3
1
  import type { GoalContract, MessageToolEvent } from '@/types'
4
- import { loadSessions, saveSessions, loadAgents, saveAgents, loadTasks, saveTasks, loadSettings } from './storage'
5
- import { log } from './logger'
6
- import { getMemoryDb } from './memory-db'
7
- import { isProtectedMainSession } from './main-session'
8
- import { logExecution } from './execution-log'
9
- import {
10
- mergeGoalContracts,
11
- parseGoalContractFromText,
12
- parseMainLoopPlan,
13
- parseMainLoopReview,
14
- } from './autonomy-contract'
15
- import { buildIdentityContext } from './heartbeat-service'
16
2
 
17
- const MAX_PENDING_EVENTS = 60
18
- const MAX_TIMELINE_EVENTS = 120
19
- const EVENT_TTL_MS = 7 * 24 * 60 * 60 * 1000
20
- const MEMORY_NOTE_MIN_INTERVAL_MS = 30 * 60 * 1000
21
- const DEFAULT_FOLLOWUP_DELAY_SEC = 45
22
- const DEFAULT_MAX_FOLLOWUP_CHAIN = 20
23
- function getMaxFollowupChain(agentId: string | undefined): number {
24
- if (agentId) {
25
- const agents = loadAgents()
26
- const agent = agents[agentId]
27
- if (typeof agent?.maxFollowupChain === 'number' && agent.maxFollowupChain > 0) {
28
- return Math.min(agent.maxFollowupChain, 100)
29
- }
30
- }
31
- const settings = loadSettings()
32
- if (typeof settings?.maxFollowupChain === 'number' && settings.maxFollowupChain > 0) {
33
- return Math.min(settings.maxFollowupChain, 100)
34
- }
35
- return DEFAULT_MAX_FOLLOWUP_CHAIN
36
- }
37
-
38
- const META_LINE_RE = /\[MAIN_LOOP_META\]\s*(\{[^\n]*\})/i
39
- const AGENT_HEARTBEAT_META_RE = /\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i
40
- const SCREENSHOT_GOAL_HINT = /\b(screenshot|screen shot|snapshot|capture)\b/i
41
- const DELIVERY_GOAL_HINT = /\b(send|deliver|return|share|upload|post|message)\b/i
42
- const SCHEDULE_GOAL_HINT = /\b(schedule|scheduled|every\s+\w+|interval|cron|recurr)\b/i
43
- const UPLOAD_ARTIFACT_HINT = /(?:sandbox:)?\/api\/uploads\/[^\s)\]]+|https?:\/\/[^\s)\]]+\.(?:png|jpe?g|webp|gif|pdf)\b/i
44
- const SENT_ARTIFACT_HINT = /\b(sent|shared|uploaded|returned)\b[^.]*\b(screenshot|snapshot|image|file)\b/i
45
-
46
- const COMPANION_GOAL_PROMPT = `
47
- ## Identity & Vibe
48
- You are a persistent companion.
49
- 1. **Identity**: Embody your creature, theme, and vibe. Your emoji is your signature.
50
- 2. **Workspace Context**: Respect the current workspace. Read IDENTITY.md and HEARTBEAT.md if they exist.
51
- 3. **Continuity**: Maintain awareness of the user's long-term journey. Proactively help with open-ended goals without being asked for every step.
52
- `.trim()
53
-
54
- interface MainLoopSessionMessageLike {
55
- text?: string
56
- }
57
-
58
- interface MainLoopSessionEvidenceLike {
59
- messages?: MainLoopSessionMessageLike[]
60
- }
61
-
62
- export interface MainLoopEvent {
63
- id: string
64
- type: string
65
- text: string
66
- createdAt: number
67
- }
68
-
69
- export interface MainLoopTimelineEntry {
70
- id: string
71
- at: number
72
- source: string
73
- note: string
74
- status?: 'idle' | 'progress' | 'blocked' | 'ok' | 'reflection'
75
- }
3
+ const LEGACY_META_LINE_RE = /\[(?:MAIN_LOOP_META|MAIN_LOOP_PLAN|MAIN_LOOP_REVIEW|AGENT_HEARTBEAT_META)\]\s*(\{[^\n]*\})?/i
76
4
 
77
5
  export interface MainLoopState {
78
6
  goal: string | null
@@ -88,8 +16,19 @@ export interface MainLoopState {
88
16
  paused: boolean
89
17
  status: 'idle' | 'progress' | 'blocked' | 'ok'
90
18
  autonomyMode: 'assist' | 'autonomous'
91
- pendingEvents: MainLoopEvent[]
92
- timeline: MainLoopTimelineEntry[]
19
+ pendingEvents: Array<{
20
+ id: string
21
+ type: string
22
+ text: string
23
+ createdAt: number
24
+ }>
25
+ timeline: Array<{
26
+ id: string
27
+ at: number
28
+ source: string
29
+ note: string
30
+ status?: 'idle' | 'progress' | 'blocked' | 'ok' | 'reflection'
31
+ }>
93
32
  missionTokens: number
94
33
  missionCostUsd: number
95
34
  followupChainCount: number
@@ -102,16 +41,6 @@ export interface MainLoopState {
102
41
  updatedAt: number
103
42
  }
104
43
 
105
- interface MainLoopMeta {
106
- status?: 'idle' | 'progress' | 'blocked' | 'ok'
107
- summary?: string
108
- next_action?: string
109
- follow_up?: boolean
110
- delay_sec?: number
111
- goal?: string
112
- consume_event_ids?: string[]
113
- }
114
-
115
44
  export interface MainLoopFollowupRequest {
116
45
  message: string
117
46
  delayMs: number
@@ -137,1038 +66,34 @@ export interface HandleMainLoopRunResultInput {
137
66
  estimatedCost?: number
138
67
  }
139
68
 
140
- function toOneLine(value: string, max = 240): string {
141
- return (value || '').replace(/\s+/g, ' ').trim().slice(0, max)
142
- }
143
-
144
- function normalizeMemoryText(value: string): string {
145
- return (value || '').replace(/\s+/g, ' ').trim()
146
- }
147
-
148
- function clampInt(value: unknown, fallback: number, min: number, max: number): number {
149
- const parsed = typeof value === 'number'
150
- ? value
151
- : typeof value === 'string'
152
- ? Number.parseInt(value, 10)
153
- : Number.NaN
154
- if (!Number.isFinite(parsed)) return fallback
155
- return Math.max(min, Math.min(max, Math.trunc(parsed)))
156
- }
157
-
158
- function pruneEvents(events: MainLoopEvent[], now = Date.now()): MainLoopEvent[] {
159
- const minTs = now - EVENT_TTL_MS
160
- const fresh = events.filter((e) => e && typeof e.createdAt === 'number' && e.createdAt >= minTs)
161
- if (fresh.length <= MAX_PENDING_EVENTS) return fresh
162
- return fresh.slice(fresh.length - MAX_PENDING_EVENTS)
163
- }
164
-
165
- function pruneTimeline(entries: MainLoopTimelineEntry[], now = Date.now()): MainLoopTimelineEntry[] {
166
- const minTs = now - EVENT_TTL_MS
167
- const fresh = entries.filter((e) => e && typeof e.at === 'number' && e.at >= minTs && typeof e.note === 'string' && e.note.trim())
168
- if (fresh.length <= MAX_TIMELINE_EVENTS) return fresh
169
- return fresh.slice(fresh.length - MAX_TIMELINE_EVENTS)
170
- }
171
-
172
- function appendTimeline(
173
- state: MainLoopState,
174
- source: string,
175
- note: string,
176
- now = Date.now(),
177
- status?: 'idle' | 'progress' | 'blocked' | 'ok' | 'reflection',
178
- ) {
179
- const normalizedNote = toOneLine(note, 400)
180
- if (!normalizedNote) return
181
- const recent = state.timeline.at(-1)
182
- if (recent && recent.source === source && recent.note === normalizedNote && now - recent.at < 45_000) return
183
- state.timeline.push({
184
- id: `tl_${genId()}`,
185
- at: now,
186
- source,
187
- note: normalizedNote,
188
- status,
189
- })
190
- state.timeline = pruneTimeline(state.timeline, now)
191
- }
192
-
193
- function computeMomentumScore(state: MainLoopState): number {
194
- const baseByStatus = {
195
- idle: 40,
196
- progress: 72,
197
- blocked: 20,
198
- ok: 94,
199
- } as const
200
- let score: number = baseByStatus[state.status]
201
- score -= Math.min(20, state.metaMissCount * 3)
202
- score -= Math.min(12, Math.max(0, state.pendingEvents.length - 4) * 2)
203
- if (state.paused) score = Math.min(score, 35)
204
- return clampInt(score, 0, 0, 100)
205
- }
206
-
207
- function normalizeStringList(input: unknown, maxItems: number, maxChars: number): string[] {
208
- if (!Array.isArray(input)) return []
209
- const seen = new Set<string>()
210
- const out: string[] = []
211
- for (const raw of input) {
212
- if (typeof raw !== 'string') continue
213
- const value = raw.replace(/\s+/g, ' ').trim().slice(0, maxChars)
214
- if (!value) continue
215
- const key = value.toLowerCase()
216
- if (seen.has(key)) continue
217
- seen.add(key)
218
- out.push(value)
219
- if (out.length >= maxItems) break
220
- }
221
- return out
222
- }
223
-
224
- function normalizeGoalContract(raw: any): GoalContract | null {
225
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
226
- const objective = typeof raw.objective === 'string' ? raw.objective.trim().slice(0, 300) : ''
227
- if (!objective) return null
228
- const constraints = normalizeStringList(raw.constraints, 10, 220)
229
- const budgetUsd = typeof raw.budgetUsd === 'number'
230
- ? Math.max(0, Math.min(1_000_000, raw.budgetUsd))
231
- : null
232
- const deadlineAt = typeof raw.deadlineAt === 'number' && Number.isFinite(raw.deadlineAt)
233
- ? Math.trunc(raw.deadlineAt)
234
- : null
235
- const successMetric = typeof raw.successMetric === 'string'
236
- ? raw.successMetric.trim().slice(0, 220) || null
237
- : null
238
- return {
239
- objective,
240
- constraints: constraints.length ? constraints : undefined,
241
- budgetUsd,
242
- deadlineAt,
243
- successMetric,
244
- }
245
- }
246
-
247
- function normalizeState(raw: any, now = Date.now()): MainLoopState {
248
- const status = raw?.status === 'blocked' || raw?.status === 'ok' || raw?.status === 'progress' || raw?.status === 'idle'
249
- ? raw.status
250
- : 'idle'
251
-
252
- const pendingRaw = Array.isArray(raw?.pendingEvents) ? raw.pendingEvents : []
253
- const pendingEvents = pruneEvents(
254
- pendingRaw
255
- .map((e: any) => {
256
- const text = toOneLine(typeof e?.text === 'string' ? e.text : '')
257
- if (!text) return null
258
- return {
259
- id: typeof e?.id === 'string' && e.id.trim() ? e.id.trim() : `evt_${genId(3)}`,
260
- type: typeof e?.type === 'string' && e.type.trim() ? e.type.trim() : 'event',
261
- text,
262
- createdAt: typeof e?.createdAt === 'number' ? e.createdAt : now,
263
- } as MainLoopEvent
264
- })
265
- .filter(Boolean) as MainLoopEvent[],
266
- now,
267
- )
268
-
269
- const timelineRaw = Array.isArray(raw?.timeline) ? raw.timeline : []
270
- const timeline = pruneTimeline(
271
- timelineRaw
272
- .map((entry: any) => {
273
- const note = toOneLine(typeof entry?.note === 'string' ? entry.note : '', 400)
274
- if (!note) return null
275
- const status = entry?.status === 'blocked' || entry?.status === 'ok' || entry?.status === 'progress' || entry?.status === 'idle'
276
- ? entry.status
277
- : undefined
278
- return {
279
- id: typeof entry?.id === 'string' && entry.id.trim() ? entry.id.trim() : `tl_${genId(3)}`,
280
- at: typeof entry?.at === 'number' ? entry.at : now,
281
- source: typeof entry?.source === 'string' && entry.source.trim() ? entry.source.trim() : 'event',
282
- note,
283
- status,
284
- } as MainLoopTimelineEntry
285
- })
286
- .filter(Boolean) as MainLoopTimelineEntry[],
287
- now,
288
- )
289
-
290
- const normalized: MainLoopState = {
291
- goal: typeof raw?.goal === 'string' && raw.goal.trim() ? raw.goal.trim().slice(0, 600) : null,
292
- goalContract: normalizeGoalContract(raw?.goalContract),
293
- status,
294
- summary: typeof raw?.summary === 'string' && raw.summary.trim() ? raw.summary.trim().slice(0, 800) : null,
295
- nextAction: typeof raw?.nextAction === 'string' && raw.nextAction.trim() ? raw.nextAction.trim().slice(0, 600) : null,
296
- planSteps: normalizeStringList(raw?.planSteps, 10, 220),
297
- currentPlanStep: typeof raw?.currentPlanStep === 'string' && raw.currentPlanStep.trim()
298
- ? raw.currentPlanStep.trim().slice(0, 220)
299
- : null,
300
- reviewNote: typeof raw?.reviewNote === 'string' && raw.reviewNote.trim()
301
- ? raw.reviewNote.trim().slice(0, 320)
302
- : null,
303
- reviewConfidence: typeof raw?.reviewConfidence === 'number' && Number.isFinite(raw.reviewConfidence)
304
- ? Math.max(0, Math.min(1, raw.reviewConfidence))
305
- : null,
306
- missionTaskId: typeof raw?.missionTaskId === 'string' && raw.missionTaskId.trim() ? raw.missionTaskId.trim() : null,
307
- momentumScore: clampInt(raw?.momentumScore, 40, 0, 100),
308
- paused: raw?.paused === true,
309
- autonomyMode: raw?.autonomyMode === 'assist' ? 'assist' : 'autonomous',
310
- pendingEvents,
311
- timeline,
312
- missionTokens: typeof raw?.missionTokens === 'number' && Number.isFinite(raw.missionTokens) ? raw.missionTokens : 0,
313
- missionCostUsd: typeof raw?.missionCostUsd === 'number' && Number.isFinite(raw.missionCostUsd) ? raw.missionCostUsd : 0,
314
- followupChainCount: clampInt(raw?.followupChainCount, 0, 0, 100),
315
- metaMissCount: clampInt(raw?.metaMissCount, 0, 0, 100),
316
- workingMemoryNotes: normalizeStringList(raw?.workingMemoryNotes, 24, 260),
317
- lastMemoryNoteAt: typeof raw?.lastMemoryNoteAt === 'number' ? raw.lastMemoryNoteAt : null,
318
- lastPlannedAt: typeof raw?.lastPlannedAt === 'number' ? raw.lastPlannedAt : null,
319
- lastReviewedAt: typeof raw?.lastReviewedAt === 'number' ? raw.lastReviewedAt : null,
320
- lastTickAt: typeof raw?.lastTickAt === 'number' ? raw.lastTickAt : null,
321
- updatedAt: typeof raw?.updatedAt === 'number' ? raw.updatedAt : now,
322
- }
323
- if (!normalized.goal && normalized.goalContract?.objective) {
324
- normalized.goal = normalized.goalContract.objective
325
- }
326
- normalized.momentumScore = computeMomentumScore(normalized)
327
- return normalized
328
- }
329
-
330
- function appendEvent(state: MainLoopState, type: string, text: string, now = Date.now()): boolean {
331
- const normalizedText = toOneLine(text)
332
- if (!normalizedText) return false
333
- const recent = state.pendingEvents.at(-1)
334
- if (recent && recent.type === type && recent.text === normalizedText && now - recent.createdAt < 60_000) {
335
- return false
336
- }
337
- state.pendingEvents.push({
338
- id: `evt_${genId()}`,
339
- type,
340
- text: normalizedText,
341
- createdAt: now,
342
- })
343
- state.pendingEvents = pruneEvents(state.pendingEvents, now)
344
- return true
345
- }
346
-
347
- function appendWorkingMemoryNote(state: MainLoopState, note: string) {
348
- const value = toOneLine(note, 260)
349
- if (!value) return
350
- const existing = state.workingMemoryNotes || []
351
- if (existing.length && existing[existing.length - 1] === value) return
352
- state.workingMemoryNotes = [...existing.slice(-23), value]
353
- }
354
-
355
- function inferGoalFromUserMessage(message: string): string | null {
356
- const text = (message || '').trim()
357
- if (!text) return null
358
- if (/^SWARM_MAIN_(MISSION_TICK|AUTO_FOLLOWUP)\b/i.test(text)) return null
359
- if (/^SWARM_HEARTBEAT_CHECK\b/i.test(text)) return null
360
- if (/^(ok|okay|cool|thanks|thx|got it|nice|yep|yeah|nope|nah)[.! ]*$/i.test(text)) return null
361
- return text.slice(0, 600)
362
- }
363
-
364
- function inferGoalFromSessionMessages(session: any): string | null {
365
- const msgs = Array.isArray(session?.messages) ? session.messages : []
366
- for (let i = msgs.length - 1; i >= 0; i -= 1) {
367
- const msg = msgs[i]
368
- if (msg?.role !== 'user') continue
369
- const inferred = inferGoalFromUserMessage(typeof msg?.text === 'string' ? msg.text : '')
370
- if (inferred) return inferred
371
- }
372
- return null
373
- }
374
-
375
- function parseMainLoopMeta(text: string): MainLoopMeta | null {
376
- const raw = (text || '').trim()
377
- if (!raw) return null
378
-
379
- const markerMatch = raw.match(META_LINE_RE)
380
- const parseCandidate = markerMatch?.[1]
381
- if (parseCandidate) {
382
- try {
383
- const parsed = JSON.parse(parseCandidate)
384
- return normalizeMeta(parsed)
385
- } catch {
386
- // fall through
387
- }
388
- }
389
-
390
- // Fallback: parse any one-line JSON that appears to be the meta payload.
391
- for (const line of raw.split('\n')) {
392
- const trimmed = line.trim()
393
- if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue
394
- if (!trimmed.includes('follow_up') && !trimmed.includes('next_action') && !trimmed.includes('consume_event_ids')) continue
395
- try {
396
- const parsed = JSON.parse(trimmed)
397
- return normalizeMeta(parsed)
398
- } catch {
399
- // skip malformed candidate lines
400
- }
401
- }
402
-
403
- return null
404
- }
405
-
406
- function normalizeMeta(raw: any): MainLoopMeta {
407
- const status = raw?.status === 'blocked' || raw?.status === 'ok' || raw?.status === 'progress' || raw?.status === 'idle'
408
- ? raw.status
409
- : undefined
410
-
411
- const consumeIds = Array.isArray(raw?.consume_event_ids)
412
- ? raw.consume_event_ids
413
- .map((v: unknown) => (typeof v === 'string' ? v.trim() : ''))
414
- .filter(Boolean)
415
- : undefined
416
-
417
- const followUp = typeof raw?.follow_up === 'boolean'
418
- ? raw.follow_up
419
- : typeof raw?.follow_up === 'string'
420
- ? raw.follow_up.trim().toLowerCase() === 'true'
421
- : undefined
422
-
423
- return {
424
- status,
425
- summary: typeof raw?.summary === 'string' ? raw.summary.trim().slice(0, 800) : undefined,
426
- next_action: typeof raw?.next_action === 'string' ? raw.next_action.trim().slice(0, 600) : undefined,
427
- follow_up: followUp,
428
- delay_sec: clampInt(raw?.delay_sec, DEFAULT_FOLLOWUP_DELAY_SEC, 5, 900),
429
- goal: typeof raw?.goal === 'string' ? raw.goal.trim().slice(0, 600) : undefined,
430
- consume_event_ids: consumeIds,
431
- }
432
- }
433
-
434
- function consumeEvents(state: MainLoopState, ids: string[] | undefined) {
435
- if (!ids?.length) return
436
- const remove = new Set(ids)
437
- state.pendingEvents = state.pendingEvents.filter((event) => !remove.has(event.id))
438
- }
439
-
440
- function buildPendingEventLines(state: MainLoopState): string {
441
- if (!state.pendingEvents.length) return 'Pending events:\n- none'
442
- const lines = state.pendingEvents
443
- .slice(-10)
444
- .map((event) => `- ${event.id} | ${event.type} | ${event.text}`)
445
- .join('\n')
446
- return `Pending events (oldest → newest):\n${lines}`
447
- }
448
-
449
- function buildTimelineLines(state: MainLoopState): string {
450
- if (!state.timeline.length) return 'Recent mission timeline:\n- none'
451
- const lines = state.timeline
452
- .slice(-5)
453
- .map((entry) => {
454
- const ts = new Date(entry.at).toISOString().slice(11, 19)
455
- const status = entry.status ? ` [${entry.status}]` : ''
456
- return `- ${ts} ${entry.source}${status}: ${entry.note}`
457
- })
458
- .join('\n')
459
- return `Recent mission timeline:\n${lines}`
69
+ export function isMainSession(_session: unknown): boolean {
70
+ return false
460
71
  }
461
72
 
462
- function buildGoalContractLines(state: MainLoopState): string[] {
463
- const contract = state.goalContract
464
- if (!contract?.objective) return []
465
- const lines = [
466
- `contract_objective: ${contract.objective}`,
467
- ]
468
- if (contract.constraints?.length) lines.push(`contract_constraints: ${contract.constraints.join(' | ')}`)
469
- if (typeof contract.budgetUsd === 'number') lines.push(`contract_budget_usd: ${contract.budgetUsd}`)
470
- if (typeof contract.deadlineAt === 'number') lines.push(`contract_deadline_iso: ${new Date(contract.deadlineAt).toISOString()}`)
471
- if (contract.successMetric) lines.push(`contract_success_metric: ${contract.successMetric}`)
472
- return lines
73
+ export function buildMainLoopHeartbeatPrompt(_session: unknown, fallbackPrompt: string): string {
74
+ return fallbackPrompt
473
75
  }
474
76
 
475
- function missionNeedsScreenshotArtifactEvidence(state: MainLoopState): boolean {
476
- const haystack = [
477
- state.goal || '',
478
- state.goalContract?.objective || '',
479
- state.goalContract?.successMetric || '',
480
- state.nextAction || '',
481
- ...(state.planSteps || []),
482
- state.currentPlanStep || '',
483
- ].join(' ')
484
- if (!SCREENSHOT_GOAL_HINT.test(haystack)) return false
485
- return DELIVERY_GOAL_HINT.test(haystack) || SCHEDULE_GOAL_HINT.test(haystack)
486
- }
487
-
488
- function missionHasScreenshotArtifactEvidence(session: MainLoopSessionEvidenceLike | null | undefined, state: MainLoopState, additionalText = ''): boolean {
489
- const candidates: string[] = [
490
- state.summary || '',
491
- additionalText || '',
492
- ]
493
- if (Array.isArray(session?.messages)) {
494
- for (let i = session.messages.length - 1; i >= 0 && candidates.length < 16; i--) {
495
- const text = typeof session.messages[i]?.text === 'string' ? session.messages[i].text! : ''
496
- if (text && text.trim()) candidates.push(text)
497
- }
498
- }
499
- return candidates.some((value) => UPLOAD_ARTIFACT_HINT.test(value) || SENT_ARTIFACT_HINT.test(value))
500
- }
501
-
502
- function getMissionCompletionGateReason(session: MainLoopSessionEvidenceLike | null | undefined, state: MainLoopState, additionalText = ''): string | null {
503
- if (!missionNeedsScreenshotArtifactEvidence(state)) return null
504
- if (missionHasScreenshotArtifactEvidence(session, state, additionalText)) return null
505
- return 'Mission requires screenshot artifact evidence (upload link or explicit sent screenshot confirmation) before completion.'
506
- }
507
-
508
- function upsertMissionTask(session: any, state: MainLoopState, now: number): string | null {
509
- if (!state.goal) return state.missionTaskId || null
510
-
511
- const tasks = loadTasks()
512
- let task = state.missionTaskId ? tasks[state.missionTaskId] : null
513
- if (!task) {
514
- task = Object.values(tasks).find((t: any) =>
515
- t?.sessionId === session.id
516
- && t?.title?.startsWith('Mission:')
517
- && t?.status !== 'archived'
518
- ) as any || null
519
- }
520
-
521
- const title = `Mission: ${state.goal.slice(0, 140)}`
522
- const statusMap = {
523
- idle: 'backlog',
524
- progress: 'running',
525
- reflection: 'running',
526
- blocked: 'failed',
527
- ok: 'completed',
528
- } as const
529
- let mappedStatus = statusMap[state.status]
530
- const completionGateReason = mappedStatus === 'completed'
531
- ? getMissionCompletionGateReason(session, state)
532
- : null
533
- if (completionGateReason) mappedStatus = 'running'
534
-
535
- let changed = false
536
- const contractLines = buildGoalContractLines(state)
537
- const planLines = state.planSteps.length
538
- ? [`plan_steps: ${state.planSteps.join(' -> ')}`]
539
- : []
540
- if (state.currentPlanStep) planLines.push(`current_plan_step: ${state.currentPlanStep}`)
541
- if (state.reviewNote) planLines.push(`latest_review: ${state.reviewNote}`)
542
-
543
- const baseDescription = [
544
- 'Autonomous mission goal tracked from main loop.',
545
- `Goal: ${state.goal}`,
546
- state.nextAction ? `Next action: ${state.nextAction}` : '',
547
- completionGateReason ? `Completion gate: ${completionGateReason}` : '',
548
- ...contractLines,
549
- ...planLines,
550
- ].filter(Boolean).join('\n')
551
-
552
- if (!task) {
553
- const id = genId()
554
- task = {
555
- id,
556
- title,
557
- description: baseDescription,
558
- status: mappedStatus,
559
- agentId: session.agentId || 'default',
560
- sessionId: session.id,
561
- result: state.summary || null,
562
- error: state.status === 'blocked' ? (state.summary || 'Blocked') : null,
563
- createdAt: now,
564
- updatedAt: now,
565
- startedAt: mappedStatus === 'running' ? now : null,
566
- completedAt: mappedStatus === 'completed' ? now : null,
567
- queuedAt: null,
568
- archivedAt: null,
569
- comments: [],
570
- images: [],
571
- validation: null,
572
- }
573
- tasks[id] = task
574
- changed = true
575
- } else {
576
- if (task.title !== title) {
577
- task.title = title
578
- changed = true
579
- }
580
- const nextDescription = baseDescription
581
- if (task.description !== nextDescription) {
582
- task.description = nextDescription
583
- changed = true
584
- }
585
- if (task.status !== mappedStatus) {
586
- task.status = mappedStatus
587
- changed = true
588
- if (mappedStatus === 'running' && !task.startedAt) task.startedAt = now
589
- if (mappedStatus === 'completed') task.completedAt = now
590
- }
591
- const nextResult = state.summary || task.result || null
592
- if (task.result !== nextResult) {
593
- task.result = nextResult
594
- changed = true
595
- }
596
- const nextError = mappedStatus === 'failed'
597
- ? (state.summary || state.nextAction || 'Blocked')
598
- : null
599
- if (task.error !== nextError) {
600
- task.error = nextError
601
- changed = true
602
- }
603
- if (changed) task.updatedAt = now
604
- tasks[task.id] = task
605
- }
606
-
607
- if (changed) {
608
- saveTasks(tasks)
609
- }
610
- return task?.id || null
611
- }
612
-
613
- function maybeStoreMissionMemoryNote(
614
- session: any,
615
- state: MainLoopState,
616
- now: number,
617
- source: string,
618
- force = false,
619
- ) {
620
- if (!Array.isArray(session?.tools) || !session.tools.includes('memory')) return
621
- if (!state.goal) return
622
- if (!force && state.lastMemoryNoteAt && (now - state.lastMemoryNoteAt) < MEMORY_NOTE_MIN_INTERVAL_MS) return
623
-
624
- const summary = state.summary || 'No summary'
625
- const next = state.nextAction || 'No next action'
626
- const title = `Mission ${state.status}: ${state.goal.slice(0, 72)}`
627
- const content = [
628
- `source: ${source}`,
629
- `status: ${state.status}`,
630
- `momentum: ${state.momentumScore}/100`,
631
- `goal: ${state.goal}`,
632
- ...buildGoalContractLines(state),
633
- state.planSteps.length ? `plan_steps: ${state.planSteps.join(' -> ')}` : '',
634
- state.currentPlanStep ? `current_plan_step: ${state.currentPlanStep}` : '',
635
- `summary: ${summary}`,
636
- `next_action: ${next}`,
637
- state.reviewNote ? `review: ${state.reviewNote}` : '',
638
- typeof state.reviewConfidence === 'number' ? `review_confidence: ${state.reviewConfidence}` : '',
639
- state.missionTaskId ? `mission_task_id: ${state.missionTaskId}` : '',
640
- typeof state.missionTokens === 'number' ? `mission_tokens: ${state.missionTokens}` : '',
641
- typeof state.missionCostUsd === 'number' ? `mission_cost_usd: $${state.missionCostUsd.toFixed(4)}` : '',
642
- state.workingMemoryNotes?.length ? `working_notes: ${state.workingMemoryNotes.slice(-5).join('; ')}` : '',
643
- ].filter(Boolean).join('\n')
644
-
645
- try {
646
- const memDb = getMemoryDb()
647
- const latest = memDb.getLatestBySessionCategory?.(session.id, 'mission')
648
- if (latest) {
649
- const sameTitle = normalizeMemoryText(latest.title) === normalizeMemoryText(title)
650
- const sameContent = normalizeMemoryText(latest.content) === normalizeMemoryText(content)
651
- if (sameTitle && sameContent) {
652
- state.lastMemoryNoteAt = now
653
- return
654
- }
655
- }
656
- memDb.add({
657
- agentId: session.agentId || null,
658
- sessionId: session.id,
659
- category: 'mission',
660
- title,
661
- content,
662
- })
663
- state.lastMemoryNoteAt = now
664
- logExecution(session.id, 'mission_checkpoint', `Checkpoint: ${toOneLine(state.goal || '', 120)}`, {
665
- agentId: session.agentId,
666
- detail: { momentumScore: state.momentumScore, followupChainCount: state.followupChainCount, missionTokens: state.missionTokens, missionCostUsd: state.missionCostUsd },
667
- })
668
- } catch (err: unknown) {
669
- appendEvent(state, 'memory_note_error', `Failed to store mission memory note: ${toOneLine(err instanceof Error ? err.message : String(err), 240)}`, now)
670
- }
671
- }
672
-
673
- function buildFollowupPrompt(state: MainLoopState, opts?: { hasMemoryTool?: boolean; agent?: Record<string, unknown> | null; session?: Record<string, unknown> | null }): string {
674
- const hasMemoryTool = opts?.hasMemoryTool === true
675
- const identityContext = buildIdentityContext(opts?.session, opts?.agent)
676
- const goal = state.goal || 'No explicit goal yet. Continue with the strongest actionable objective from recent context.'
677
- const nextAction = state.nextAction || 'Determine the next highest-impact action and execute it.'
678
- const contractLines = buildGoalContractLines(state)
679
- return [
680
- 'SWARM_MAIN_AUTO_FOLLOWUP',
681
- identityContext,
682
- COMPANION_GOAL_PROMPT,
683
- `Mission goal: ${goal}`,
684
- `Next action to execute now: ${nextAction}`,
685
- `Current status: ${state.status}`,
686
- `Mission task id: ${state.missionTaskId || 'none'}`,
687
- `Momentum score: ${state.momentumScore}/100`,
688
- ...contractLines,
689
- state.planSteps.length ? `Current plan steps: ${state.planSteps.join(' -> ')}` : '',
690
- state.currentPlanStep ? `Current plan step: ${state.currentPlanStep}` : '',
691
- state.reviewNote ? `Last review: ${state.reviewNote}` : '',
692
- buildPendingEventLines(state),
693
- buildTimelineLines(state),
694
- state.planSteps.length === 0 && state.followupChainCount === 0
695
- ? 'Before executing, break the mission goal into 3-7 concrete subtasks. Output a [MAIN_LOOP_PLAN] JSON line with your plan, then execute the first step immediately.'
696
- : '',
697
- 'Act autonomously. Use available tools to execute work, verify results, and keep momentum.',
698
- state.autonomyMode === 'assist'
699
- ? 'Assist mode: execute safe internal analysis by default, and ask before irreversible external side effects (sending messages, purchases, account mutations).'
700
- : 'Autonomous mode: execute safe next actions without waiting for confirmation; ask only when blocked by permissions, credentials, or policy.',
701
- 'Do not ask clarifying questions unless blocked by missing credentials, permissions, or safety constraints.',
702
- hasMemoryTool
703
- ? 'Use memory_tool actively: recall relevant prior notes before acting, and store a concise note after each meaningful step.'
704
- : 'memory_tool is unavailable in this session. Keep concise progress summaries in your status/meta output.',
705
- 'If you are blocked by missing credentials, permissions, or policy limits, say exactly what is blocked and the smallest unblock needed.',
706
- 'For screenshot/image delivery goals (including scheduled captures), do not report status "ok" until a real artifact exists (upload link or explicit sent-file confirmation).',
707
- 'If no meaningful action remains right now, reply exactly HEARTBEAT_OK.',
708
- 'Otherwise include a concise human update, then append exactly one [MAIN_LOOP_META] JSON line.',
709
- 'Optionally append one [MAIN_LOOP_PLAN] JSON line when you create/revise a plan.',
710
- 'Optionally append one [MAIN_LOOP_REVIEW] JSON line when you review recent execution results.',
711
- '[MAIN_LOOP_META] {"status":"progress|ok|blocked|idle","summary":"...","next_action":"...","follow_up":true|false,"delay_sec":45,"goal":"optional","consume_event_ids":["evt_..."]}',
712
- '[MAIN_LOOP_PLAN] {"steps":["..."],"current_step":"..."}',
713
- '[MAIN_LOOP_REVIEW] {"note":"...","confidence":0.0,"needs_replan":false}',
714
- ].join('\n')
715
- }
716
-
717
- export function isMainSession(session: any): boolean {
718
- return isProtectedMainSession(session)
719
- }
720
-
721
- export function buildMainLoopHeartbeatPrompt(session: any, fallbackPrompt: string): string {
722
- const now = Date.now()
723
- const agents = loadAgents()
724
- const agent = session.agentId ? agents[session.agentId] : null
725
- const identityContext = buildIdentityContext(session, agent)
726
- const state = normalizeState(session?.mainLoopState, now)
727
- const goal = state.goal || inferGoalFromSessionMessages(session) || null
728
- const hasMemoryTool = Array.isArray(session?.tools) && session.tools.includes('memory')
729
-
730
- const promptGoal = goal || 'No explicit mission captured yet. Infer the mission from recent user instructions and continue proactively.'
731
- const promptSummary = state.summary || 'No prior mission summary yet.'
732
- const promptNextAction = state.nextAction || 'No queued action. Determine one.'
733
- const contractLines = buildGoalContractLines(state)
734
-
735
- return [
736
- 'SWARM_MAIN_MISSION_TICK',
737
- identityContext,
738
- COMPANION_GOAL_PROMPT,
739
- `Time: ${new Date(now).toISOString()}`,
740
- `Mission goal: ${promptGoal}`,
741
- `Current status: ${state.status}`,
742
- `Mission paused: ${state.paused ? 'yes' : 'no'}`,
743
- `Autonomy mode: ${state.autonomyMode}`,
744
- `Mission task id: ${state.missionTaskId || 'none'}`,
745
- `Momentum score: ${state.momentumScore}/100`,
746
- ...contractLines,
747
- state.planSteps.length ? `Current plan steps: ${state.planSteps.join(' -> ')}` : '',
748
- state.currentPlanStep ? `Current plan step: ${state.currentPlanStep}` : '',
749
- state.reviewNote ? `Last review: ${state.reviewNote}` : '',
750
- `Last summary: ${toOneLine(promptSummary, 500)}`,
751
- `Last next action: ${toOneLine(promptNextAction, 500)}`,
752
- buildPendingEventLines(state),
753
- buildTimelineLines(state),
754
- 'You are running the main autonomous mission loop. Continue executing toward the goal with initiative.',
755
- state.autonomyMode === 'assist'
756
- ? 'Assist mode is active: execute safe internal work and ask before irreversible external side effects.'
757
- : 'Autonomous mode is active: execute safe next actions without waiting for confirmation; only ask when blocked.',
758
- 'Use tools where needed, verify outcomes, and avoid vague status-only replies.',
759
- 'Do not ask broad exploratory questions when a safe next action exists. Pick a reasonable assumption, execute, and adapt from evidence.',
760
- 'Do not ask clarifying questions unless blocked by missing credentials, permissions, or safety constraints.',
761
- hasMemoryTool
762
- ? 'Use memory_tool actively: recall relevant prior notes before acting, and store concise notes about progress, constraints, and next step after each meaningful action.'
763
- : 'If memory_tool is unavailable, keep concise state in summary/next_action and continue execution.',
764
- 'Use a planner-executor-review loop: keep a concrete step plan, execute one meaningful step, then self-review and either continue or re-plan.',
765
- 'For screenshot/image delivery goals (including scheduled captures), do not report status "ok" until a real artifact exists (upload link or explicit sent-file confirmation).',
766
- 'If nothing important changed and no action is needed now, reply exactly HEARTBEAT_OK.',
767
- 'Otherwise: provide a concise human-readable update, then append exactly one [MAIN_LOOP_META] JSON line.',
768
- 'Optionally append one [MAIN_LOOP_PLAN] JSON line when creating/updating plan steps.',
769
- 'Optionally append one [MAIN_LOOP_REVIEW] JSON line after execution review.',
770
- '[MAIN_LOOP_META] {"status":"progress|ok|blocked|idle","summary":"...","next_action":"...","follow_up":true|false,"delay_sec":45,"goal":"optional","consume_event_ids":["evt_..."]}',
771
- '[MAIN_LOOP_PLAN] {"steps":["..."],"current_step":"..."}',
772
- '[MAIN_LOOP_REVIEW] {"note":"...","confidence":0.0,"needs_replan":false}',
773
- 'The [MAIN_LOOP_META] JSON must be valid, on one line, and only appear once.',
774
- `Fallback prompt context: ${fallbackPrompt || 'SWARM_HEARTBEAT_CHECK'}`,
775
- ].join('\n')
776
- }
777
-
778
- export function stripMainLoopMetaForPersistence(text: string, internal: boolean): string {
779
- if (!internal) return text
780
- if (!text) return ''
781
- return text
77
+ export function stripMainLoopMetaForPersistence(text: string): string {
78
+ return (text || '')
782
79
  .split('\n')
783
- .filter((line) => !line.includes('[MAIN_LOOP_META]') && !line.includes('[MAIN_LOOP_PLAN]') && !line.includes('[MAIN_LOOP_REVIEW]') && !line.includes('[AGENT_HEARTBEAT_META]'))
80
+ .filter((line) => !LEGACY_META_LINE_RE.test(line))
784
81
  .join('\n')
785
82
  .trim()
786
83
  }
787
84
 
788
- export function getMainLoopStateForSession(sessionId: string): MainLoopState | null {
789
- const sessions = loadSessions()
790
- const session = sessions[sessionId]
791
- if (!session || !isMainSession(session)) return null
792
- return normalizeState(session.mainLoopState)
793
- }
794
-
795
- export function setMainLoopStateForSession(sessionId: string, patch: Partial<MainLoopState>): MainLoopState | null {
796
- const sessions = loadSessions()
797
- const session = sessions[sessionId]
798
- if (!session || !isMainSession(session)) return null
799
- const now = Date.now()
800
- const state = normalizeState(session.mainLoopState, now)
801
-
802
- if (typeof patch.goal === 'string') state.goal = patch.goal.trim().slice(0, 600) || null
803
- if (patch.goal === null) state.goal = null
804
- if (patch.goalContract !== undefined) state.goalContract = normalizeGoalContract(patch.goalContract)
805
- if (patch.status === 'idle' || patch.status === 'progress' || patch.status === 'blocked' || patch.status === 'ok') state.status = patch.status
806
- if (typeof patch.summary === 'string') state.summary = patch.summary.trim().slice(0, 800) || null
807
- if (patch.summary === null) state.summary = null
808
- if (typeof patch.nextAction === 'string') state.nextAction = patch.nextAction.trim().slice(0, 600) || null
809
- if (patch.nextAction === null) state.nextAction = null
810
- if (Array.isArray(patch.planSteps)) state.planSteps = normalizeStringList(patch.planSteps, 10, 220)
811
- if (typeof patch.currentPlanStep === 'string') state.currentPlanStep = patch.currentPlanStep.trim().slice(0, 220) || null
812
- if (patch.currentPlanStep === null) state.currentPlanStep = null
813
- if (typeof patch.reviewNote === 'string') state.reviewNote = patch.reviewNote.trim().slice(0, 320) || null
814
- if (patch.reviewNote === null) state.reviewNote = null
815
- if (typeof patch.reviewConfidence === 'number' && Number.isFinite(patch.reviewConfidence)) {
816
- state.reviewConfidence = Math.max(0, Math.min(1, patch.reviewConfidence))
817
- }
818
- if (patch.reviewConfidence === null) state.reviewConfidence = null
819
- if (typeof patch.missionTaskId === 'string') state.missionTaskId = patch.missionTaskId.trim() || null
820
- if (patch.missionTaskId === null) state.missionTaskId = null
821
- if (typeof patch.momentumScore === 'number') state.momentumScore = clampInt(patch.momentumScore, state.momentumScore, 0, 100)
822
- if (typeof patch.paused === 'boolean') state.paused = patch.paused
823
- if (patch.autonomyMode === 'assist' || patch.autonomyMode === 'autonomous') state.autonomyMode = patch.autonomyMode
824
- if (Array.isArray(patch.pendingEvents)) state.pendingEvents = pruneEvents(patch.pendingEvents, now)
825
- if (Array.isArray(patch.timeline)) state.timeline = pruneTimeline(patch.timeline, now)
826
- if (typeof patch.followupChainCount === 'number') state.followupChainCount = clampInt(patch.followupChainCount, state.followupChainCount, 0, 100)
827
- if (typeof patch.metaMissCount === 'number') state.metaMissCount = clampInt(patch.metaMissCount, state.metaMissCount, 0, 100)
828
- if (Array.isArray(patch.workingMemoryNotes)) state.workingMemoryNotes = normalizeStringList(patch.workingMemoryNotes, 24, 260)
829
- if (typeof patch.lastMemoryNoteAt === 'number') state.lastMemoryNoteAt = patch.lastMemoryNoteAt
830
- if (patch.lastMemoryNoteAt === null) state.lastMemoryNoteAt = null
831
- if (typeof patch.lastPlannedAt === 'number') state.lastPlannedAt = patch.lastPlannedAt
832
- if (patch.lastPlannedAt === null) state.lastPlannedAt = null
833
- if (typeof patch.lastReviewedAt === 'number') state.lastReviewedAt = patch.lastReviewedAt
834
- if (patch.lastReviewedAt === null) state.lastReviewedAt = null
835
-
836
- state.momentumScore = computeMomentumScore(state)
837
- state.updatedAt = now
838
- session.mainLoopState = state
839
- sessions[sessionId] = session
840
- saveSessions(sessions)
841
- return state
85
+ export function getMainLoopStateForSession(_sessionId: string): MainLoopState | null {
86
+ return null
842
87
  }
843
88
 
844
- export function pushMainLoopEventToMainSessions(input: PushMainLoopEventInput): number {
845
- const text = toOneLine(input.text)
846
- if (!text) return 0
847
-
848
- const sessions = loadSessions()
849
- const now = Date.now()
850
- let changed = 0
851
-
852
- for (const session of Object.values(sessions) as any[]) {
853
- if (!isMainSession(session)) continue
854
- if (input.user && session.user && session.user !== input.user) continue
855
-
856
- const state = normalizeState(session.mainLoopState, now)
857
- const appended = appendEvent(state, input.type || 'event', text, now)
858
- if (!appended) continue
859
- appendTimeline(state, input.type || 'event', text, now, state.status)
860
- state.momentumScore = computeMomentumScore(state)
861
- state.updatedAt = now
862
- session.mainLoopState = state
863
- changed += 1
864
- }
865
-
866
- if (changed > 0) {
867
- saveSessions(sessions)
868
- log.info('main-loop', `Queued event for ${changed} main session(s)`, {
869
- type: input.type,
870
- text,
871
- user: input.user || null,
872
- })
873
- }
874
-
875
- return changed
89
+ export function setMainLoopStateForSession(_sessionId: string, _patch: Partial<MainLoopState>): MainLoopState | null {
90
+ return null
876
91
  }
877
92
 
878
- const AgentHeartbeatMetaSchema = z.object({
879
- goal: z.string().trim().optional(),
880
- status: z.enum(['progress', 'ok', 'idle', 'blocked']).optional(),
881
- next_action: z.string().trim().optional(),
882
- }).passthrough()
883
-
884
- type AgentHeartbeatMeta = z.infer<typeof AgentHeartbeatMetaSchema>
885
-
886
- function parseAgentHeartbeatMeta(text: string): AgentHeartbeatMeta | null {
887
- const raw = (text || '').trim()
888
- if (!raw) return null
889
- const match = raw.match(AGENT_HEARTBEAT_META_RE)
890
- if (!match?.[1]) return null
891
- try {
892
- const parsed = JSON.parse(match[1])
893
- return AgentHeartbeatMetaSchema.parse(parsed)
894
- } catch {
895
- return null
896
- }
93
+ export function pushMainLoopEventToMainSessions(_input: PushMainLoopEventInput): number {
94
+ return 0
897
95
  }
898
96
 
899
- function handleAgentHeartbeatResult(session: any, input: HandleMainLoopRunResultInput): null {
900
- if (!input.internal || input.source !== 'heartbeat') return null
901
- if (!session.agentId) return null
902
- const text = input.resultText || ''
903
- if (!text.trim()) return null
904
-
905
- const meta = parseAgentHeartbeatMeta(text)
906
- if (!meta) return null
907
-
908
- const agents = loadAgents()
909
- const agent = agents[session.agentId]
910
- if (!agent) return null
911
-
912
- let changed = false
913
- if (meta.goal && meta.goal !== agent.heartbeatGoal) {
914
- agent.heartbeatGoal = meta.goal
915
- changed = true
916
- log.info('agent-heartbeat', `Goal updated for agent ${agent.name}: ${meta.goal.slice(0, 120)}`)
917
- }
918
- if (meta.next_action) {
919
- agent.heartbeatNextAction = meta.next_action
920
- changed = true
921
- }
922
- if (meta.status) {
923
- agent.heartbeatStatus = meta.status
924
- changed = true
925
- }
926
-
927
- if (changed) {
928
- agents[session.agentId] = agent
929
- saveAgents(agents)
930
- }
97
+ export function handleMainLoopRunResult(_input: HandleMainLoopRunResultInput): MainLoopFollowupRequest | null {
931
98
  return null
932
99
  }
933
-
934
- export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): MainLoopFollowupRequest | null {
935
- const sessions = loadSessions()
936
- const session = sessions[input.sessionId]
937
- if (!session) return null
938
- if (!isProtectedMainSession(session)) return handleAgentHeartbeatResult(session, input)
939
-
940
- const now = Date.now()
941
- const state = normalizeState(session.mainLoopState, now)
942
- const hasMemoryTool = Array.isArray(session.tools) && session.tools.includes('memory')
943
- state.pendingEvents = pruneEvents(state.pendingEvents, now)
944
- let forceMemoryNote = false
945
-
946
- const userGoal = inferGoalFromUserMessage(input.message)
947
- const userGoalContract = parseGoalContractFromText(input.message)
948
- if (!input.internal) {
949
- if (userGoal) {
950
- state.goal = userGoal
951
- if (userGoalContract) state.goalContract = mergeGoalContracts(state.goalContract, userGoalContract)
952
- state.status = 'progress'
953
- appendEvent(state, 'user_instruction', `User goal updated: ${userGoal}`, now)
954
- appendTimeline(state, 'user_goal', `Goal updated: ${userGoal}`, now, state.status)
955
- appendWorkingMemoryNote(state, `goal:${userGoal}`)
956
- forceMemoryNote = true
957
- logExecution(input.sessionId, 'mission_start', `New goal: ${toOneLine(userGoal, 200)}`, {
958
- agentId: session.agentId,
959
- detail: { goal: userGoal, planSteps: state.planSteps },
960
- })
961
- } else if (userGoalContract?.objective) {
962
- state.goal = userGoalContract.objective
963
- state.goalContract = mergeGoalContracts(state.goalContract, userGoalContract)
964
- state.status = 'progress'
965
- appendTimeline(state, 'user_goal_contract', `Goal contract updated: ${userGoalContract.objective}`, now, state.status)
966
- appendWorkingMemoryNote(state, `contract:${userGoalContract.objective}`)
967
- forceMemoryNote = true
968
- logExecution(input.sessionId, 'mission_start', `New goal contract: ${toOneLine(userGoalContract.objective, 200)}`, {
969
- agentId: session.agentId,
970
- detail: { goal: userGoalContract.objective, contract: userGoalContract, planSteps: state.planSteps },
971
- })
972
- }
973
- state.followupChainCount = 0
974
- state.missionTokens = 0
975
- state.missionCostUsd = 0
976
- }
977
-
978
- // Accumulate per-mission token/cost tracking
979
- if (typeof input.inputTokens === 'number') {
980
- state.missionTokens = (state.missionTokens || 0) + input.inputTokens + (input.outputTokens || 0)
981
- }
982
- if (typeof input.estimatedCost === 'number') {
983
- state.missionCostUsd = (state.missionCostUsd || 0) + input.estimatedCost
984
- }
985
-
986
- if (state.paused && input.internal) {
987
- appendTimeline(state, 'paused_skip', `Skipped internal tick from ${input.source} because mission is paused.`, now, state.status)
988
- state.momentumScore = computeMomentumScore(state)
989
- state.updatedAt = now
990
- session.mainLoopState = state
991
- sessions[input.sessionId] = session
992
- saveSessions(sessions)
993
- return null
994
- }
995
-
996
- if (input.error) {
997
- appendEvent(state, 'run_error', `Run error (${input.source}): ${toOneLine(input.error, 400)}`, now)
998
- appendTimeline(state, 'run_error', `Run error (${input.source}): ${toOneLine(input.error, 220)}`, now, 'blocked')
999
- state.status = 'blocked'
1000
- appendWorkingMemoryNote(state, `blocked:${toOneLine(input.error, 120)}`)
1001
- forceMemoryNote = true
1002
- }
1003
-
1004
- for (const event of input.toolEvents || []) {
1005
- if (!event?.error) continue
1006
- appendEvent(
1007
- state,
1008
- 'tool_error',
1009
- `Tool ${event.name || 'unknown'} error: ${toOneLine(event.output || event.input || 'unknown error', 400)}`,
1010
- now,
1011
- )
1012
- appendTimeline(
1013
- state,
1014
- 'tool_error',
1015
- `Tool ${event.name || 'unknown'} error encountered.`,
1016
- now,
1017
- 'blocked',
1018
- )
1019
- forceMemoryNote = true
1020
- }
1021
-
1022
- let followup: MainLoopFollowupRequest | null = null
1023
- const shouldAutoKickFromUserGoal = !input.internal
1024
- && !input.error
1025
- && (!!userGoal || !!userGoalContract?.objective)
1026
- && !state.paused
1027
- && state.autonomyMode === 'autonomous'
1028
-
1029
- if (shouldAutoKickFromUserGoal) {
1030
- followup = {
1031
- message: buildFollowupPrompt(state, { hasMemoryTool, agent: session.agentId ? loadAgents()[session.agentId] : null, session }),
1032
- delayMs: 1500,
1033
- dedupeKey: `main-loop-user-kickoff:${input.sessionId}`,
1034
- }
1035
- appendTimeline(state, 'followup', 'Queued autonomous kickoff follow-up from new user goal.', now, state.status)
1036
- }
1037
-
1038
- if (input.internal) {
1039
- state.lastTickAt = now
1040
- const trimmedText = (input.resultText || '').trim()
1041
- const isHeartbeatOk = /^HEARTBEAT_OK$/i.test(trimmedText)
1042
- const meta = parseMainLoopMeta(trimmedText)
1043
- const planMeta = parseMainLoopPlan(trimmedText)
1044
- const reviewMeta = parseMainLoopReview(trimmedText)
1045
-
1046
- if (planMeta) {
1047
- if (planMeta.steps?.length) {
1048
- state.planSteps = planMeta.steps
1049
- state.lastPlannedAt = now
1050
- appendWorkingMemoryNote(state, `plan:${planMeta.steps.join(' -> ')}`)
1051
- }
1052
- if (planMeta.current_step) {
1053
- state.currentPlanStep = planMeta.current_step
1054
- state.lastPlannedAt = now
1055
- }
1056
- appendTimeline(state, 'plan', `Plan updated${planMeta.current_step ? ` at step: ${planMeta.current_step}` : ''}.`, now, state.status)
1057
- }
1058
-
1059
- if (reviewMeta) {
1060
- if (reviewMeta.note) {
1061
- state.reviewNote = reviewMeta.note
1062
- appendWorkingMemoryNote(state, `review:${reviewMeta.note}`)
1063
- }
1064
- if (typeof reviewMeta.confidence === 'number') state.reviewConfidence = reviewMeta.confidence
1065
- state.lastReviewedAt = now
1066
- if (reviewMeta.needs_replan === true && state.planSteps.length > 0) {
1067
- appendEvent(state, 'review_replan', 'Execution review requested replanning.', now)
1068
- }
1069
- appendTimeline(state, 'review', reviewMeta.note || 'Execution review updated.', now, state.status)
1070
- }
1071
-
1072
- if (meta) {
1073
- state.metaMissCount = 0
1074
- if (meta.goal) {
1075
- state.goal = meta.goal
1076
- const metaGoalContract = parseGoalContractFromText(meta.goal)
1077
- if (metaGoalContract) state.goalContract = mergeGoalContracts(state.goalContract, metaGoalContract)
1078
- }
1079
- if (meta.status) state.status = meta.status
1080
- if (meta.summary) state.summary = meta.summary
1081
- if (meta.next_action) state.nextAction = meta.next_action
1082
- if (meta.summary) appendWorkingMemoryNote(state, `summary:${toOneLine(meta.summary, 180)}`)
1083
- if (meta.next_action) appendWorkingMemoryNote(state, `next:${toOneLine(meta.next_action, 180)}`)
1084
- appendTimeline(
1085
- state,
1086
- 'meta',
1087
- `Meta update: status=${meta.status || state.status}; summary=${toOneLine(meta.summary || state.summary || 'none', 140)}`,
1088
- now,
1089
- meta.status || state.status,
1090
- )
1091
- consumeEvents(state, meta.consume_event_ids)
1092
-
1093
- // Budget enforcement: check mission cost against goalContract.budgetUsd
1094
- const budgetUsd = state.goalContract?.budgetUsd
1095
- if (typeof budgetUsd === 'number' && budgetUsd > 0 && typeof state.missionCostUsd === 'number') {
1096
- const usageRatio = state.missionCostUsd / budgetUsd
1097
- if (usageRatio >= 1.0 && !state.paused) {
1098
- state.paused = true
1099
- state.status = 'blocked'
1100
- appendTimeline(state, 'budget_exceeded', `Mission budget exceeded ($${state.missionCostUsd.toFixed(4)} / $${budgetUsd.toFixed(4)}). Mission paused.`, now, 'blocked')
1101
- appendEvent(state, 'budget_exceeded', `Budget limit reached: $${state.missionCostUsd.toFixed(4)} of $${budgetUsd.toFixed(4)}`, now)
1102
- logExecution(input.sessionId, 'budget_warning', `Budget exceeded: $${state.missionCostUsd.toFixed(4)} / $${budgetUsd.toFixed(4)}`, {
1103
- agentId: session.agentId,
1104
- detail: { missionCostUsd: state.missionCostUsd, budgetUsd, missionTokens: state.missionTokens },
1105
- })
1106
- } else if (usageRatio >= 0.8) {
1107
- appendEvent(state, 'budget_warning', `Mission approaching budget limit: $${state.missionCostUsd.toFixed(4)} of $${budgetUsd.toFixed(4)} (${Math.round(usageRatio * 100)}%)`, now)
1108
- logExecution(input.sessionId, 'budget_warning', `Budget at ${Math.round(usageRatio * 100)}%: $${state.missionCostUsd.toFixed(4)} / $${budgetUsd.toFixed(4)}`, {
1109
- agentId: session.agentId,
1110
- detail: { missionCostUsd: state.missionCostUsd, budgetUsd, usagePercent: Math.round(usageRatio * 100) },
1111
- })
1112
- }
1113
- }
1114
-
1115
- if (meta.follow_up === true && !input.error && !isHeartbeatOk && !state.paused && state.followupChainCount < getMaxFollowupChain(session.agentId)) {
1116
- state.followupChainCount += 1
1117
- const delaySec = clampInt(meta.delay_sec, DEFAULT_FOLLOWUP_DELAY_SEC, 5, 900)
1118
- followup = {
1119
- message: buildFollowupPrompt(state, { hasMemoryTool, agent: session.agentId ? loadAgents()[session.agentId] : null, session }),
1120
- delayMs: delaySec * 1000,
1121
- dedupeKey: `main-loop-followup:${input.sessionId}`,
1122
- }
1123
- appendTimeline(state, 'followup', `Queued chained follow-up in ${delaySec}s.`, now, state.status)
1124
- } else if (meta.follow_up === false || isHeartbeatOk) {
1125
- state.followupChainCount = 0
1126
- }
1127
- if (state.status === 'ok' || state.status === 'blocked') {
1128
- forceMemoryNote = true
1129
- if (state.status === 'ok') {
1130
- logExecution(input.sessionId, 'mission_complete', `Mission completed: ${toOneLine(state.goal || 'unknown', 200)}`, {
1131
- agentId: session.agentId,
1132
- detail: { momentumScore: state.momentumScore, followupChainCount: state.followupChainCount, missionTokens: state.missionTokens, missionCostUsd: state.missionCostUsd },
1133
- })
1134
- }
1135
- }
1136
- } else if (!isHeartbeatOk && trimmedText) {
1137
- state.metaMissCount = Math.min(100, state.metaMissCount + 1)
1138
- state.summary = toOneLine(trimmedText, 700)
1139
- appendWorkingMemoryNote(state, `inferred:${toOneLine(trimmedText, 160)}`)
1140
- if (state.status === 'idle') state.status = 'progress'
1141
- appendEvent(state, 'meta_missing', 'Main-loop reply missing [MAIN_LOOP_META] contract; state inferred from text.', now)
1142
- appendTimeline(state, 'meta_missing', 'Missing [MAIN_LOOP_META]; inferred state from plain text.', now, state.status)
1143
- } else if (isHeartbeatOk) {
1144
- state.metaMissCount = 0
1145
- appendTimeline(state, 'heartbeat_ok', 'Heartbeat returned HEARTBEAT_OK.', now, state.status)
1146
- }
1147
- }
1148
-
1149
- if (input.internal && state.status === 'ok') {
1150
- const completionGateReason = getMissionCompletionGateReason(session, state, input.resultText || '')
1151
- if (completionGateReason) {
1152
- state.status = 'progress'
1153
- if (!state.nextAction || /^no queued action/i.test(state.nextAction)) {
1154
- state.nextAction = 'Wait for the next schedule run and verify a screenshot artifact link is delivered.'
1155
- }
1156
- appendEvent(state, 'completion_gate', completionGateReason, now)
1157
- appendTimeline(state, 'completion_gate', 'Holding completion until screenshot artifact evidence is observed.', now, state.status)
1158
- appendWorkingMemoryNote(state, `gate:${toOneLine(completionGateReason, 180)}`)
1159
- }
1160
- }
1161
-
1162
- state.missionTaskId = upsertMissionTask(session, state, now)
1163
- const shouldWritePeriodicMemory = !!state.summary && state.status === 'progress'
1164
- maybeStoreMissionMemoryNote(session, state, now, input.source, forceMemoryNote || shouldWritePeriodicMemory)
1165
-
1166
- state.momentumScore = computeMomentumScore(state)
1167
-
1168
- state.updatedAt = now
1169
- session.mainLoopState = state
1170
- sessions[input.sessionId] = session
1171
- saveSessions(sessions)
1172
-
1173
- return followup
1174
- }