@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
@@ -0,0 +1,397 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { spawnSync } from 'child_process'
4
+ import * as cheerio from 'cheerio'
5
+ import { findBinaryOnPath } from './session-tools/context'
6
+
7
+ const TEXT_EXTENSIONS = new Set([
8
+ '.txt', '.md', '.markdown', '.json', '.jsonl', '.csv', '.tsv',
9
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs',
10
+ '.java', '.yaml', '.yml', '.sql', '.xml', '.css', '.scss', '.html', '.htm',
11
+ ])
12
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tif', '.tiff'])
13
+
14
+ export interface StructuredTable {
15
+ name: string
16
+ headers: string[]
17
+ rows: Array<Record<string, unknown>>
18
+ rowCount: number
19
+ }
20
+
21
+ export interface DocumentArtifact {
22
+ filePath: string
23
+ fileName: string
24
+ ext: string
25
+ method: string
26
+ text: string
27
+ metadata: Record<string, unknown>
28
+ tables: StructuredTable[]
29
+ }
30
+
31
+ function trimText(text: string, maxChars = 200_000): string {
32
+ const normalized = text.replace(/\r\n/g, '\n').replace(/\u0000/g, '').trim()
33
+ if (normalized.length <= maxChars) return normalized
34
+ return `${normalized.slice(0, maxChars)}\n... [truncated]`
35
+ }
36
+
37
+ function normalizeScalar(value: unknown): unknown {
38
+ if (value === undefined) return null
39
+ if (value === null) return null
40
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value
41
+ if (value instanceof Date) return value.toISOString()
42
+ return String(value)
43
+ }
44
+
45
+ function parseDelimitedText(input: string, delimiter: string): string[][] {
46
+ const rows: string[][] = []
47
+ let row: string[] = []
48
+ let field = ''
49
+ let inQuotes = false
50
+
51
+ for (let index = 0; index < input.length; index += 1) {
52
+ const char = input[index]
53
+ const next = input[index + 1]
54
+
55
+ if (inQuotes) {
56
+ if (char === '"' && next === '"') {
57
+ field += '"'
58
+ index += 1
59
+ continue
60
+ }
61
+ if (char === '"') {
62
+ inQuotes = false
63
+ continue
64
+ }
65
+ field += char
66
+ continue
67
+ }
68
+
69
+ if (char === '"') {
70
+ inQuotes = true
71
+ continue
72
+ }
73
+ if (char === delimiter) {
74
+ row.push(field)
75
+ field = ''
76
+ continue
77
+ }
78
+ if (char === '\n') {
79
+ row.push(field)
80
+ rows.push(row)
81
+ row = []
82
+ field = ''
83
+ continue
84
+ }
85
+ if (char === '\r') continue
86
+ field += char
87
+ }
88
+
89
+ if (field.length > 0 || row.length > 0) {
90
+ row.push(field)
91
+ rows.push(row)
92
+ }
93
+
94
+ return rows.filter((cells) => cells.some((cell) => cell.trim().length > 0))
95
+ }
96
+
97
+ function matrixToTable(name: string, matrix: string[][]): StructuredTable {
98
+ if (matrix.length === 0) return { name, headers: [], rows: [], rowCount: 0 }
99
+ const headerRow = matrix[0].map((cell, index) => cell.trim() || `column_${index + 1}`)
100
+ const rows = matrix.slice(1).map((cells) => {
101
+ const row: Record<string, unknown> = {}
102
+ for (let index = 0; index < headerRow.length; index += 1) {
103
+ row[headerRow[index]] = cells[index] ?? ''
104
+ }
105
+ return row
106
+ })
107
+ return {
108
+ name,
109
+ headers: headerRow,
110
+ rows,
111
+ rowCount: rows.length,
112
+ }
113
+ }
114
+
115
+ function objectsToTable(name: string, rows: Array<Record<string, unknown>>): StructuredTable {
116
+ const headers = Array.from(new Set(rows.flatMap((row) => Object.keys(row))))
117
+ const normalizedRows = rows.map((row) => {
118
+ const out: Record<string, unknown> = {}
119
+ for (const header of headers) out[header] = normalizeScalar(row[header])
120
+ return out
121
+ })
122
+ return {
123
+ name,
124
+ headers,
125
+ rows: normalizedRows,
126
+ rowCount: normalizedRows.length,
127
+ }
128
+ }
129
+
130
+ function tablesToText(tables: StructuredTable[]): string {
131
+ return tables
132
+ .map((table) => {
133
+ const header = table.headers.join('\t')
134
+ const body = table.rows.slice(0, 100).map((row) => table.headers.map((key) => String(row[key] ?? '')).join('\t')).join('\n')
135
+ return `${table.name}\n${header}${body ? `\n${body}` : ''}`
136
+ })
137
+ .join('\n\n')
138
+ }
139
+
140
+ function worksheetRowToArray(values: unknown): unknown[] {
141
+ if (Array.isArray(values)) return values.slice(1)
142
+ if (values && typeof values === 'object') {
143
+ return Object.entries(values as Record<string, unknown>)
144
+ .filter(([key]) => Number.isFinite(Number(key)) && Number(key) >= 1)
145
+ .sort((left, right) => Number(left[0]) - Number(right[0]))
146
+ .map(([, value]) => value)
147
+ }
148
+ return []
149
+ }
150
+
151
+ function listZipEntries(filePath: string): { entries: string[]; method: string } {
152
+ const unzip = findBinaryOnPath('unzip') || findBinaryOnPath('zipinfo')
153
+ if (!unzip) throw new Error('ZIP listing requires `unzip` or `zipinfo` on PATH.')
154
+ const args = path.basename(unzip).includes('zipinfo') ? ['-1', filePath] : ['-Z1', filePath]
155
+ const out = spawnSync(unzip, args, {
156
+ encoding: 'utf-8',
157
+ maxBuffer: 10 * 1024 * 1024,
158
+ timeout: 20_000,
159
+ })
160
+ if ((out.status ?? 1) !== 0) {
161
+ throw new Error(`Failed to inspect ZIP: ${(out.stderr || out.stdout || '').trim() || 'unknown error'}`)
162
+ }
163
+ const entries = (out.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
164
+ return { entries, method: path.basename(unzip) }
165
+ }
166
+
167
+ async function extractPdfText(filePath: string): Promise<{ text: string; method: string }> {
168
+ try {
169
+ const pdfMod = await import(/* webpackIgnore: true */ 'pdf-parse')
170
+ const pdfParse = ((pdfMod as Record<string, unknown>).default ?? pdfMod) as (buf: Buffer) => Promise<{ text: string }>
171
+ const result = await pdfParse(fs.readFileSync(filePath))
172
+ if ((result.text || '').trim()) {
173
+ return { text: result.text, method: 'pdf-parse' }
174
+ }
175
+ } catch {
176
+ // fall through to pdftotext
177
+ }
178
+
179
+ const pdftotext = findBinaryOnPath('pdftotext')
180
+ if (!pdftotext) throw new Error('PDF extraction requires `pdf-parse` or `pdftotext`.')
181
+ const out = spawnSync(pdftotext, ['-layout', '-nopgbrk', '-q', filePath, '-'], {
182
+ encoding: 'utf-8',
183
+ maxBuffer: 25 * 1024 * 1024,
184
+ timeout: 20_000,
185
+ })
186
+ if ((out.status ?? 1) !== 0) {
187
+ throw new Error(`pdftotext failed: ${(out.stderr || out.stdout || '').trim() || 'unknown error'}`)
188
+ }
189
+ return { text: out.stdout || '', method: 'pdftotext' }
190
+ }
191
+
192
+ function extractImageText(filePath: string): { text: string; method: string } {
193
+ const tesseract = findBinaryOnPath('tesseract')
194
+ if (!tesseract) {
195
+ throw new Error('Image OCR requires `tesseract` on PATH.')
196
+ }
197
+ const out = spawnSync(tesseract, [filePath, 'stdout', '--psm', '6'], {
198
+ encoding: 'utf-8',
199
+ maxBuffer: 25 * 1024 * 1024,
200
+ timeout: 30_000,
201
+ })
202
+ if ((out.status ?? 1) !== 0) {
203
+ throw new Error(`tesseract failed: ${(out.stderr || out.stdout || '').trim() || 'unknown error'}`)
204
+ }
205
+ return { text: out.stdout || '', method: 'tesseract' }
206
+ }
207
+
208
+ function extractRichText(filePath: string): { text: string; method: string } {
209
+ const textutil = findBinaryOnPath('textutil')
210
+ if (!textutil) throw new Error('DOC/DOCX/RTF extraction requires `textutil` on PATH.')
211
+ const out = spawnSync(textutil, ['-convert', 'txt', '-stdout', filePath], {
212
+ encoding: 'utf-8',
213
+ maxBuffer: 25 * 1024 * 1024,
214
+ timeout: 20_000,
215
+ })
216
+ if ((out.status ?? 1) !== 0 || !(out.stdout || '').trim()) {
217
+ throw new Error(`textutil failed: ${(out.stderr || out.stdout || '').trim() || 'unknown error'}`)
218
+ }
219
+ return { text: out.stdout || '', method: 'textutil' }
220
+ }
221
+
222
+ export async function extractDocumentArtifact(filePath: string, options?: { maxChars?: number; preferOcr?: boolean }): Promise<DocumentArtifact> {
223
+ const resolved = path.resolve(filePath)
224
+ if (!fs.existsSync(resolved)) throw new Error(`File not found: ${filePath}`)
225
+ const stat = fs.statSync(resolved)
226
+ if (!stat.isFile()) throw new Error(`Expected a file: ${filePath}`)
227
+
228
+ const ext = path.extname(resolved).toLowerCase()
229
+ const metadata: Record<string, unknown> = {
230
+ sizeBytes: stat.size,
231
+ modifiedAt: stat.mtimeMs,
232
+ }
233
+ const maxChars = options?.maxChars || 200_000
234
+ let text = ''
235
+ let method = 'utf8'
236
+ let tables: StructuredTable[] = []
237
+
238
+ if (ext === '.pdf') {
239
+ const pdf = await extractPdfText(resolved)
240
+ text = pdf.text
241
+ method = pdf.method
242
+ } else if (ext === '.csv' || ext === '.tsv') {
243
+ const delimiter = ext === '.tsv' ? '\t' : ','
244
+ const raw = fs.readFileSync(resolved, 'utf-8')
245
+ const table = matrixToTable(path.basename(resolved), parseDelimitedText(raw, delimiter))
246
+ tables = [table]
247
+ text = tablesToText(tables)
248
+ method = ext === '.tsv' ? 'tsv' : 'csv'
249
+ } else if (ext === '.xlsx' || ext === '.xlsm') {
250
+ const ExcelJS = await import('exceljs')
251
+ const workbook = new ExcelJS.Workbook()
252
+ await workbook.xlsx.readFile(resolved)
253
+ tables = workbook.worksheets.map((worksheet) => {
254
+ const matrix: string[][] = []
255
+ worksheet.eachRow((row) => {
256
+ matrix.push(worksheetRowToArray(row.values).map((cell) => String(normalizeScalar(cell) ?? '')))
257
+ })
258
+ return matrixToTable(worksheet.name, matrix)
259
+ }).filter((table) => table.headers.length > 0 || table.rowCount > 0)
260
+ text = tablesToText(tables)
261
+ method = 'exceljs'
262
+ metadata.sheetNames = workbook.worksheets.map((sheet) => sheet.name)
263
+ } else if (ext === '.json') {
264
+ const raw = fs.readFileSync(resolved, 'utf-8')
265
+ text = raw
266
+ method = 'json'
267
+ try {
268
+ const parsed = JSON.parse(raw)
269
+ if (Array.isArray(parsed) && parsed.every((row) => row && typeof row === 'object' && !Array.isArray(row))) {
270
+ tables = [objectsToTable(path.basename(resolved), parsed as Array<Record<string, unknown>>)]
271
+ }
272
+ } catch {
273
+ // keep raw json text only
274
+ }
275
+ } else if (ext === '.html' || ext === '.htm') {
276
+ const html = fs.readFileSync(resolved, 'utf-8')
277
+ const $ = cheerio.load(html)
278
+ $('script, style, noscript').remove()
279
+ text = $('body').text() || $.text()
280
+ method = 'html-strip'
281
+ } else if (ext === '.zip') {
282
+ const zip = listZipEntries(resolved)
283
+ text = zip.entries.join('\n')
284
+ method = zip.method
285
+ metadata.entries = zip.entries
286
+ } else if (ext === '.doc' || ext === '.docx' || ext === '.rtf') {
287
+ const rich = extractRichText(resolved)
288
+ text = rich.text
289
+ method = rich.method
290
+ } else if (IMAGE_EXTENSIONS.has(ext) || options?.preferOcr === true) {
291
+ const image = extractImageText(resolved)
292
+ text = image.text
293
+ method = image.method
294
+ } else if (TEXT_EXTENSIONS.has(ext) || !ext) {
295
+ text = fs.readFileSync(resolved, 'utf-8')
296
+ method = 'utf8'
297
+ } else {
298
+ text = fs.readFileSync(resolved, 'utf-8')
299
+ method = 'utf8-fallback'
300
+ }
301
+
302
+ return {
303
+ filePath: resolved,
304
+ fileName: path.basename(resolved),
305
+ ext,
306
+ method,
307
+ text: trimText(text, maxChars),
308
+ metadata,
309
+ tables,
310
+ }
311
+ }
312
+
313
+ export async function loadTabularFile(filePath: string, options?: { sheetName?: string }): Promise<StructuredTable> {
314
+ const resolved = path.resolve(filePath)
315
+ const ext = path.extname(resolved).toLowerCase()
316
+ if (ext === '.csv' || ext === '.tsv') {
317
+ const delimiter = ext === '.tsv' ? '\t' : ','
318
+ return matrixToTable(path.basename(resolved), parseDelimitedText(fs.readFileSync(resolved, 'utf-8'), delimiter))
319
+ }
320
+ if (ext === '.json') {
321
+ const parsed = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
322
+ if (!Array.isArray(parsed) || !parsed.every((row) => row && typeof row === 'object' && !Array.isArray(row))) {
323
+ throw new Error('JSON table inputs must be an array of objects.')
324
+ }
325
+ return objectsToTable(path.basename(resolved), parsed as Array<Record<string, unknown>>)
326
+ }
327
+ if (ext === '.xlsx' || ext === '.xlsm') {
328
+ const ExcelJS = await import('exceljs')
329
+ const workbook = new ExcelJS.Workbook()
330
+ await workbook.xlsx.readFile(resolved)
331
+ const target = options?.sheetName
332
+ ? workbook.getWorksheet(options.sheetName)
333
+ : workbook.worksheets[0]
334
+ if (!target) throw new Error(`Worksheet not found: ${options?.sheetName || '(first worksheet)'}`)
335
+ const matrix: string[][] = []
336
+ target.eachRow((row) => {
337
+ matrix.push(worksheetRowToArray(row.values).map((cell) => String(normalizeScalar(cell) ?? '')))
338
+ })
339
+ return matrixToTable(target.name, matrix)
340
+ }
341
+ throw new Error(`Unsupported tabular file: ${ext || '(no extension)'}`)
342
+ }
343
+
344
+ export function normalizeInlineRows(value: unknown): StructuredTable {
345
+ if (!Array.isArray(value)) throw new Error('rows must be an array.')
346
+ if (value.length === 0) return { name: 'rows', headers: [], rows: [], rowCount: 0 }
347
+ if (value.every((row) => Array.isArray(row))) {
348
+ return matrixToTable('rows', value.map((row) => (row as unknown[]).map((cell) => String(normalizeScalar(cell) ?? ''))))
349
+ }
350
+ if (value.every((row) => row && typeof row === 'object' && !Array.isArray(row))) {
351
+ return objectsToTable('rows', value as Array<Record<string, unknown>>)
352
+ }
353
+ throw new Error('rows must be an array of objects or arrays.')
354
+ }
355
+
356
+ function escapeDelimitedCell(value: unknown, delimiter: string): string {
357
+ const raw = String(normalizeScalar(value) ?? '')
358
+ if (raw.includes('"') || raw.includes('\n') || raw.includes(delimiter)) {
359
+ return `"${raw.replace(/"/g, '""')}"`
360
+ }
361
+ return raw
362
+ }
363
+
364
+ export function serializeTable(table: StructuredTable, delimiter = ','): string {
365
+ const header = table.headers.map((cell) => escapeDelimitedCell(cell, delimiter)).join(delimiter)
366
+ const rows = table.rows.map((row) => table.headers.map((headerCell) => escapeDelimitedCell(row[headerCell], delimiter)).join(delimiter))
367
+ return [header, ...rows].join('\n')
368
+ }
369
+
370
+ export async function writeStructuredTable(filePath: string, table: StructuredTable): Promise<{ filePath: string; format: string }> {
371
+ const resolved = path.resolve(filePath)
372
+ const ext = path.extname(resolved).toLowerCase()
373
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
374
+
375
+ if (ext === '.json') {
376
+ fs.writeFileSync(resolved, JSON.stringify(table.rows, null, 2), 'utf-8')
377
+ return { filePath: resolved, format: 'json' }
378
+ }
379
+ if (ext === '.tsv') {
380
+ fs.writeFileSync(resolved, serializeTable(table, '\t'), 'utf-8')
381
+ return { filePath: resolved, format: 'tsv' }
382
+ }
383
+ if (ext === '.xlsx') {
384
+ const ExcelJS = await import('exceljs')
385
+ const workbook = new ExcelJS.Workbook()
386
+ const worksheet = workbook.addWorksheet(table.name || 'Sheet1')
387
+ worksheet.addRow(table.headers)
388
+ for (const row of table.rows) {
389
+ worksheet.addRow(table.headers.map((header) => row[header] ?? null))
390
+ }
391
+ await workbook.xlsx.writeFile(resolved)
392
+ return { filePath: resolved, format: 'xlsx' }
393
+ }
394
+
395
+ fs.writeFileSync(resolved, serializeTable(table, ','), 'utf-8')
396
+ return { filePath: resolved, format: 'csv' }
397
+ }
@@ -3,9 +3,9 @@ import path from 'path'
3
3
  import { loadAgents, loadSessions, loadSettings } from './storage'
4
4
  import { enqueueSessionRun, getSessionRunState } from './session-run-manager'
5
5
  import { log } from './logger'
6
- import { buildMainLoopHeartbeatPrompt, getMainLoopStateForSession, isMainSession } from './main-agent-loop'
7
6
  import { WORKSPACE_DIR } from './data-dir'
8
7
  import { drainSystemEvents } from './system-events'
8
+ import { buildIdentityContinuityContext } from './identity-continuity'
9
9
 
10
10
  const HEARTBEAT_TICK_MS = 5_000
11
11
 
@@ -188,6 +188,7 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
188
188
  if (!agent) return fallbackPrompt
189
189
 
190
190
  const identityContext = buildIdentityContext(session, agent)
191
+ const continuityContext = buildIdentityContinuityContext(session, agent)
191
192
  // Drain system events accumulated since last heartbeat
192
193
  const events = drainSystemEvents(session.id)
193
194
  const eventBlock = events.length > 0
@@ -219,6 +220,7 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
219
220
  'AGENT_HEARTBEAT_TICK',
220
221
  `Time: ${new Date().toISOString()}`,
221
222
  identityContext,
223
+ continuityContext,
222
224
  description ? `Description: ${description}` : '',
223
225
  eventBlock ? `Events since last heartbeat:\n${eventBlock}` : '',
224
226
  dynamicGoal
@@ -242,14 +244,6 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
242
244
  ].filter(Boolean).join('\n')
243
245
  }
244
246
 
245
- function applyMomentumMultiplier(intervalSec: number, momentumScore: number): number {
246
- let multiplier = 1.0
247
- if (momentumScore >= 80) multiplier = 0.5
248
- else if (momentumScore < 40) multiplier = 2.0
249
- const adjusted = Math.round(intervalSec * multiplier)
250
- return Math.max(30, Math.min(7200, adjusted))
251
- }
252
-
253
247
  function resolveInterval(obj: Record<string, any>, currentSec: number): number {
254
248
  // Prefer heartbeatInterval (duration string) over heartbeatIntervalSec (raw number)
255
249
  if (obj.heartbeatInterval !== undefined && obj.heartbeatInterval !== null) {
@@ -377,8 +371,8 @@ async function tickHeartbeats() {
377
371
 
378
372
  for (const session of Object.values(sessions) as any[]) {
379
373
  if (!session?.id) continue
380
- if (!Array.isArray(session.tools) || session.tools.length === 0) continue
381
- if (session.sessionType && session.sessionType !== 'human' && session.sessionType !== 'orchestrated') continue
374
+ if (!Array.isArray(session.plugins) || session.plugins.length === 0) continue
375
+ if (session.sessionType && session.sessionType !== 'human') continue
382
376
 
383
377
  // Check if this session or its agent has explicit heartbeat opt-in
384
378
  const agent = session.agentId ? agents[session.agentId] : null
@@ -395,10 +389,6 @@ async function tickHeartbeats() {
395
389
  const cfg = heartbeatConfigForSession(session, settings, agents)
396
390
  if (!cfg.enabled) continue
397
391
 
398
- // Apply momentum-based multiplier to heartbeat interval
399
- const momentumScore = session.mainLoopState?.momentumScore ?? 40
400
- cfg.intervalSec = applyMomentumMultiplier(cfg.intervalSec, momentumScore)
401
-
402
392
  // For sessions with explicit opt-in, use a shorter idle threshold (just intervalSec * 2).
403
393
  // For inherited/global heartbeats, keep the 180s minimum to avoid noisy auto-fire.
404
394
  const defaultIdleSec = explicitOptIn
@@ -410,38 +400,22 @@ async function tickHeartbeats() {
410
400
  const idleMs = now - lastUserAt
411
401
  if (idleMs < userIdleThresholdSec * 1000) continue
412
402
 
413
- if (isMainSession(session)) {
414
- const loopState = getMainLoopStateForSession(session.id)
415
- if (loopState?.paused) continue
416
- // Only suppress idle main sessions when heartbeat is inherited (not explicitly enabled)
417
- if (!explicitOptIn) {
418
- const loopStatus = loopState?.status || 'idle'
419
- const pendingEvents = loopState?.pendingEvents?.length || 0
420
- if ((loopStatus === 'ok' || loopStatus === 'idle') && pendingEvents === 0) continue
421
- }
422
- }
423
-
424
403
  const last = state.lastBySession.get(session.id) || 0
425
404
  if (now - last < cfg.intervalSec * 1000) continue
426
405
 
427
406
  const runState = getSessionRunState(session.id)
428
407
  if (runState.runningRunId) continue
429
408
 
430
- let heartbeatMessage: string
431
- if (isMainSession(session)) {
432
- heartbeatMessage = buildMainLoopHeartbeatPrompt(session, cfg.prompt)
433
- } else {
434
- const rawHeartbeatFileContent = readHeartbeatFile(session)
435
- const heartbeatFileContent = isHeartbeatContentEffectivelyEmpty(rawHeartbeatFileContent) ? '' : rawHeartbeatFileContent
436
- const hasGoal = !!(agent?.heartbeatGoal || agent?.description || agent?.systemPrompt || agent?.soul)
437
- const hasCustomPrompt = cfg.prompt !== DEFAULT_HEARTBEAT_PROMPT
438
- // Skip heartbeat only if there's truly nothing to drive it:
439
- // no agent goal, no HEARTBEAT.md content, AND no custom prompt configured
440
- if (!hasGoal && !heartbeatFileContent && !hasCustomPrompt) {
441
- continue
442
- }
443
- heartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
409
+ const rawHeartbeatFileContent = readHeartbeatFile(session)
410
+ const heartbeatFileContent = isHeartbeatContentEffectivelyEmpty(rawHeartbeatFileContent) ? '' : rawHeartbeatFileContent
411
+ const hasGoal = !!(agent?.heartbeatGoal || agent?.description || agent?.systemPrompt || agent?.soul)
412
+ const hasCustomPrompt = cfg.prompt !== DEFAULT_HEARTBEAT_PROMPT
413
+ // Skip heartbeat only if there's truly nothing to drive it:
414
+ // no agent goal, no HEARTBEAT.md content, AND no custom prompt configured
415
+ if (!hasGoal && !heartbeatFileContent && !hasCustomPrompt) {
416
+ continue
444
417
  }
418
+ const heartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
445
419
 
446
420
  const enqueue = enqueueSessionRun({
447
421
  sessionId: session.id,
@@ -0,0 +1,22 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { isHeartbeatSource, isInternalHeartbeatRun } from './heartbeat-source'
4
+
5
+ describe('heartbeat-source', () => {
6
+ it('treats scheduled heartbeat polls as heartbeat traffic', () => {
7
+ assert.equal(isHeartbeatSource('heartbeat'), true)
8
+ assert.equal(isInternalHeartbeatRun(true, 'heartbeat'), true)
9
+ })
10
+
11
+ it('treats wake-triggered heartbeat polls as heartbeat traffic', () => {
12
+ assert.equal(isHeartbeatSource('heartbeat-wake'), true)
13
+ assert.equal(isInternalHeartbeatRun(true, 'heartbeat-wake'), true)
14
+ })
15
+
16
+ it('does not classify other sources as heartbeat traffic', () => {
17
+ assert.equal(isHeartbeatSource('task'), false)
18
+ assert.equal(isHeartbeatSource('chat'), false)
19
+ assert.equal(isInternalHeartbeatRun(false, 'heartbeat'), false)
20
+ assert.equal(isInternalHeartbeatRun(true, 'task'), false)
21
+ })
22
+ })
@@ -0,0 +1,7 @@
1
+ export function isHeartbeatSource(source: string | null | undefined): boolean {
2
+ return source === 'heartbeat' || source === 'heartbeat-wake'
3
+ }
4
+
5
+ export function isInternalHeartbeatRun(internal: boolean | null | undefined, source: string | null | undefined): boolean {
6
+ return internal === true && isHeartbeatSource(source)
7
+ }
@@ -0,0 +1,77 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import type { Session } from '@/types'
4
+ import { buildIdentityContinuityContext, refreshSessionIdentityState } from './identity-continuity'
5
+
6
+ test('buildIdentityContinuityContext merges agent and session continuity', () => {
7
+ const block = buildIdentityContinuityContext(
8
+ {
9
+ name: 'Thread A',
10
+ conversationTone: 'technical',
11
+ identityState: {
12
+ personaLabel: 'Debugger',
13
+ relationshipSummary: 'Working with the user on a production issue.',
14
+ },
15
+ } as Partial<Session>,
16
+ {
17
+ name: 'Swarmy',
18
+ description: 'Helpful coding agent',
19
+ identityState: {
20
+ boundaries: ['Do not pretend work is complete without evidence.'],
21
+ continuityNotes: ['User prefers concise explanations.'],
22
+ },
23
+ },
24
+ )
25
+
26
+ assert.match(block, /Identity Continuity/)
27
+ assert.match(block, /Current persona: Debugger/)
28
+ assert.match(block, /Observed tone: technical/)
29
+ assert.match(block, /User prefers concise explanations/)
30
+ })
31
+
32
+ test('refreshSessionIdentityState derives fallback continuity fields', () => {
33
+ const session = {
34
+ id: 's1',
35
+ name: 'Checkout Bug',
36
+ cwd: process.cwd(),
37
+ user: 'Taylor',
38
+ provider: 'openai',
39
+ model: 'gpt-4.1',
40
+ claudeSessionId: null,
41
+ codexThreadId: null,
42
+ opencodeSessionId: null,
43
+ messages: [{ role: 'user', text: 'Help', time: 1 }],
44
+ createdAt: 1,
45
+ lastActiveAt: 1,
46
+ conversationTone: 'focused',
47
+ connectorContext: { threadId: 'thread-9', senderName: 'Taylor' },
48
+ } as Session
49
+
50
+ const state = refreshSessionIdentityState(session, {
51
+ name: 'Swarmy',
52
+ description: 'Helpful coding agent',
53
+ }, 100)
54
+
55
+ assert.equal(state.personaLabel, 'Swarmy thread thread-9')
56
+ assert.equal(state.relationshipSummary, 'Ongoing conversation with Taylor.')
57
+ assert.equal(state.toneStyle, 'focused')
58
+ assert.equal(state.updatedAt, 100)
59
+ })
60
+
61
+ test('buildIdentityContinuityContext prefers thread persona labels from connector context', () => {
62
+ const block = buildIdentityContinuityContext(
63
+ {
64
+ name: 'Connector Session',
65
+ connectorContext: {
66
+ threadId: 'thread-9',
67
+ threadPersonaLabel: 'Checkout Incident',
68
+ },
69
+ } as Partial<Session>,
70
+ {
71
+ name: 'Swarmy',
72
+ description: 'Helpful coding agent',
73
+ },
74
+ )
75
+
76
+ assert.match(block, /Current persona: Checkout Incident/)
77
+ })