@swarmclawai/swarmclaw 0.7.2 → 0.7.4

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 (274) hide show
  1. package/README.md +116 -50
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +43 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +39 -8
  12. package/src/app/api/agents/route.ts +35 -2
  13. package/src/app/api/auth/route.ts +77 -8
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  19. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  20. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  21. package/src/app/api/chats/[id]/route.ts +30 -0
  22. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  23. package/src/app/api/chats/heartbeat/route.ts +2 -1
  24. package/src/app/api/chats/route.ts +23 -1
  25. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  26. package/src/app/api/connectors/doctor/route.ts +13 -0
  27. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  28. package/src/app/api/external-agents/[id]/route.ts +31 -0
  29. package/src/app/api/external-agents/register/route.ts +3 -0
  30. package/src/app/api/external-agents/route.ts +66 -0
  31. package/src/app/api/files/open/route.ts +16 -14
  32. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  33. package/src/app/api/gateways/[id]/route.ts +79 -0
  34. package/src/app/api/gateways/route.ts +57 -0
  35. package/src/app/api/memory/maintenance/route.ts +11 -1
  36. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  37. package/src/app/api/openclaw/gateway/route.ts +10 -7
  38. package/src/app/api/openclaw/skills/route.ts +12 -4
  39. package/src/app/api/plugins/dependencies/route.ts +24 -0
  40. package/src/app/api/plugins/install/route.ts +15 -92
  41. package/src/app/api/plugins/route.ts +3 -26
  42. package/src/app/api/plugins/settings/route.ts +17 -12
  43. package/src/app/api/plugins/ui/route.ts +1 -0
  44. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  45. package/src/app/api/schedules/[id]/route.ts +38 -9
  46. package/src/app/api/schedules/route.ts +51 -28
  47. package/src/app/api/settings/route.ts +55 -17
  48. package/src/app/api/setup/doctor/route.ts +6 -4
  49. package/src/app/api/tasks/[id]/route.ts +16 -6
  50. package/src/app/api/tasks/bulk/route.ts +3 -3
  51. package/src/app/api/tasks/route.ts +9 -4
  52. package/src/app/api/webhooks/[id]/route.ts +8 -1
  53. package/src/app/page.tsx +135 -17
  54. package/src/cli/binary.test.js +142 -0
  55. package/src/cli/index.js +38 -11
  56. package/src/cli/index.test.js +195 -0
  57. package/src/cli/index.ts +21 -12
  58. package/src/cli/server-cmd.test.js +59 -0
  59. package/src/cli/spec.js +20 -2
  60. package/src/components/agents/agent-card.tsx +15 -12
  61. package/src/components/agents/agent-chat-list.tsx +101 -1
  62. package/src/components/agents/agent-list.tsx +46 -9
  63. package/src/components/agents/agent-sheet.tsx +456 -23
  64. package/src/components/agents/inspector-panel.tsx +110 -49
  65. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  66. package/src/components/auth/access-key-gate.tsx +36 -97
  67. package/src/components/auth/setup-wizard.tsx +970 -275
  68. package/src/components/chat/chat-area.tsx +70 -27
  69. package/src/components/chat/chat-card.tsx +6 -21
  70. package/src/components/chat/chat-header.tsx +263 -366
  71. package/src/components/chat/chat-list.tsx +62 -26
  72. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  73. package/src/components/chat/message-list.tsx +145 -19
  74. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  75. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  76. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  77. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  78. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  79. package/src/components/chatrooms/chatroom-view.tsx +422 -209
  80. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  81. package/src/components/connectors/connector-list.tsx +265 -127
  82. package/src/components/connectors/connector-sheet.tsx +217 -0
  83. package/src/components/gateways/gateway-sheet.tsx +567 -0
  84. package/src/components/home/home-view.tsx +128 -4
  85. package/src/components/input/chat-input.tsx +135 -86
  86. package/src/components/layout/app-layout.tsx +385 -194
  87. package/src/components/layout/mobile-header.tsx +26 -8
  88. package/src/components/memory/memory-browser.tsx +71 -6
  89. package/src/components/memory/memory-card.tsx +18 -0
  90. package/src/components/memory/memory-detail.tsx +58 -31
  91. package/src/components/memory/memory-sheet.tsx +32 -4
  92. package/src/components/plugins/plugin-list.tsx +15 -3
  93. package/src/components/plugins/plugin-sheet.tsx +118 -9
  94. package/src/components/projects/project-detail.tsx +189 -1
  95. package/src/components/providers/provider-list.tsx +158 -2
  96. package/src/components/providers/provider-sheet.tsx +81 -70
  97. package/src/components/shared/agent-picker-list.tsx +2 -2
  98. package/src/components/shared/bottom-sheet.tsx +31 -15
  99. package/src/components/shared/command-palette.tsx +111 -24
  100. package/src/components/shared/confirm-dialog.tsx +45 -30
  101. package/src/components/shared/model-combobox.tsx +90 -8
  102. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  103. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  104. package/src/components/shared/settings/section-heartbeat.tsx +88 -6
  105. package/src/components/shared/settings/section-orchestrator.tsx +6 -3
  106. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  107. package/src/components/shared/settings/section-secrets.tsx +6 -6
  108. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  109. package/src/components/shared/settings/section-voice.tsx +5 -1
  110. package/src/components/shared/settings/section-web-search.tsx +10 -2
  111. package/src/components/shared/settings/settings-page.tsx +248 -47
  112. package/src/components/tasks/approvals-panel.tsx +211 -18
  113. package/src/components/tasks/task-board.tsx +242 -46
  114. package/src/components/ui/dialog.tsx +2 -2
  115. package/src/components/usage/metrics-dashboard.tsx +74 -1
  116. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  117. package/src/components/wallets/wallet-panel.tsx +17 -5
  118. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  119. package/src/lib/auth.ts +17 -0
  120. package/src/lib/chat-streaming-state.test.ts +108 -0
  121. package/src/lib/chat-streaming-state.ts +108 -0
  122. package/src/lib/heartbeat-defaults.ts +48 -0
  123. package/src/lib/memory-presentation.ts +59 -0
  124. package/src/lib/openclaw-agent-id.test.ts +14 -0
  125. package/src/lib/openclaw-agent-id.ts +31 -0
  126. package/src/lib/provider-model-discovery-client.ts +29 -0
  127. package/src/lib/providers/index.ts +12 -5
  128. package/src/lib/runtime-loop.ts +105 -3
  129. package/src/lib/safe-storage.ts +6 -1
  130. package/src/lib/server/agent-assignment.test.ts +112 -0
  131. package/src/lib/server/agent-assignment.ts +169 -0
  132. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  133. package/src/lib/server/agent-runtime-config.ts +277 -0
  134. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  135. package/src/lib/server/approvals-auto-approve.test.ts +264 -0
  136. package/src/lib/server/approvals.ts +483 -75
  137. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  138. package/src/lib/server/browser-state.test.ts +118 -0
  139. package/src/lib/server/browser-state.ts +123 -0
  140. package/src/lib/server/build-llm.test.ts +44 -0
  141. package/src/lib/server/build-llm.ts +11 -4
  142. package/src/lib/server/builtin-plugins.ts +34 -0
  143. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  144. package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
  145. package/src/lib/server/chat-execution.ts +402 -125
  146. package/src/lib/server/chatroom-health.test.ts +26 -0
  147. package/src/lib/server/chatroom-health.ts +2 -3
  148. package/src/lib/server/chatroom-helpers.test.ts +74 -2
  149. package/src/lib/server/chatroom-helpers.ts +144 -11
  150. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  151. package/src/lib/server/connectors/discord.ts +175 -11
  152. package/src/lib/server/connectors/doctor.test.ts +80 -0
  153. package/src/lib/server/connectors/doctor.ts +116 -0
  154. package/src/lib/server/connectors/manager.ts +994 -130
  155. package/src/lib/server/connectors/policy.test.ts +222 -0
  156. package/src/lib/server/connectors/policy.ts +452 -0
  157. package/src/lib/server/connectors/slack.ts +189 -10
  158. package/src/lib/server/connectors/telegram.ts +65 -15
  159. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  160. package/src/lib/server/connectors/thread-context.ts +72 -0
  161. package/src/lib/server/connectors/types.ts +41 -11
  162. package/src/lib/server/daemon-state.ts +62 -3
  163. package/src/lib/server/data-dir.ts +13 -0
  164. package/src/lib/server/delegation-jobs.test.ts +140 -0
  165. package/src/lib/server/delegation-jobs.ts +248 -0
  166. package/src/lib/server/document-utils.test.ts +47 -0
  167. package/src/lib/server/document-utils.ts +397 -0
  168. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  169. package/src/lib/server/eval/agent-regression.ts +1742 -0
  170. package/src/lib/server/eval/runner.ts +11 -1
  171. package/src/lib/server/eval/store.ts +2 -1
  172. package/src/lib/server/heartbeat-service.ts +23 -43
  173. package/src/lib/server/heartbeat-source.test.ts +22 -0
  174. package/src/lib/server/heartbeat-source.ts +7 -0
  175. package/src/lib/server/identity-continuity.test.ts +77 -0
  176. package/src/lib/server/identity-continuity.ts +127 -0
  177. package/src/lib/server/mailbox-utils.ts +347 -0
  178. package/src/lib/server/main-agent-loop.ts +31 -964
  179. package/src/lib/server/memory-db.ts +4 -6
  180. package/src/lib/server/memory-tiers.ts +40 -0
  181. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  182. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  183. package/src/lib/server/openclaw-exec-config.ts +6 -5
  184. package/src/lib/server/openclaw-gateway.ts +123 -36
  185. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  186. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  187. package/src/lib/server/openclaw-sync.ts +3 -2
  188. package/src/lib/server/orchestrator-lg.ts +18 -8
  189. package/src/lib/server/orchestrator.ts +5 -4
  190. package/src/lib/server/playwright-proxy.mjs +27 -3
  191. package/src/lib/server/plugins.test.ts +215 -0
  192. package/src/lib/server/plugins.ts +832 -69
  193. package/src/lib/server/provider-health.ts +33 -3
  194. package/src/lib/server/provider-model-discovery.ts +481 -0
  195. package/src/lib/server/queue.ts +4 -21
  196. package/src/lib/server/runtime-settings.test.ts +119 -0
  197. package/src/lib/server/runtime-settings.ts +12 -92
  198. package/src/lib/server/schedule-normalization.ts +187 -0
  199. package/src/lib/server/scheduler.ts +2 -0
  200. package/src/lib/server/session-archive-memory.test.ts +85 -0
  201. package/src/lib/server/session-archive-memory.ts +230 -0
  202. package/src/lib/server/session-mailbox.ts +8 -18
  203. package/src/lib/server/session-reset-policy.test.ts +99 -0
  204. package/src/lib/server/session-reset-policy.ts +311 -0
  205. package/src/lib/server/session-run-manager.ts +33 -80
  206. package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
  207. package/src/lib/server/session-tools/calendar.ts +2 -12
  208. package/src/lib/server/session-tools/connector.ts +109 -8
  209. package/src/lib/server/session-tools/context.ts +14 -2
  210. package/src/lib/server/session-tools/crawl.ts +447 -0
  211. package/src/lib/server/session-tools/crud.ts +96 -34
  212. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  213. package/src/lib/server/session-tools/delegate.ts +406 -20
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  215. package/src/lib/server/session-tools/discovery.ts +40 -12
  216. package/src/lib/server/session-tools/document.ts +283 -0
  217. package/src/lib/server/session-tools/email.ts +1 -3
  218. package/src/lib/server/session-tools/extract.ts +137 -0
  219. package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
  220. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  221. package/src/lib/server/session-tools/file.ts +243 -24
  222. package/src/lib/server/session-tools/http.ts +9 -3
  223. package/src/lib/server/session-tools/human-loop.ts +227 -0
  224. package/src/lib/server/session-tools/image-gen.ts +1 -3
  225. package/src/lib/server/session-tools/index.ts +87 -2
  226. package/src/lib/server/session-tools/mailbox.ts +276 -0
  227. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  228. package/src/lib/server/session-tools/memory.ts +35 -3
  229. package/src/lib/server/session-tools/monitor.ts +162 -12
  230. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  231. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  232. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  233. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  234. package/src/lib/server/session-tools/platform.ts +142 -4
  235. package/src/lib/server/session-tools/plugin-creator.ts +95 -25
  236. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  237. package/src/lib/server/session-tools/replicate.ts +1 -3
  238. package/src/lib/server/session-tools/sandbox.ts +51 -92
  239. package/src/lib/server/session-tools/schedule.ts +20 -10
  240. package/src/lib/server/session-tools/session-info.ts +58 -4
  241. package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
  242. package/src/lib/server/session-tools/shell.ts +2 -2
  243. package/src/lib/server/session-tools/subagent.ts +195 -27
  244. package/src/lib/server/session-tools/table.ts +587 -0
  245. package/src/lib/server/session-tools/wallet.ts +13 -10
  246. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  247. package/src/lib/server/session-tools/web.ts +947 -108
  248. package/src/lib/server/storage.ts +255 -10
  249. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  250. package/src/lib/server/stream-agent-chat.ts +185 -25
  251. package/src/lib/server/structured-extract.test.ts +72 -0
  252. package/src/lib/server/structured-extract.ts +373 -0
  253. package/src/lib/server/task-mention.test.ts +16 -2
  254. package/src/lib/server/task-mention.ts +61 -11
  255. package/src/lib/server/tool-aliases.ts +80 -12
  256. package/src/lib/server/tool-capability-policy.ts +7 -1
  257. package/src/lib/server/tool-retry.ts +2 -0
  258. package/src/lib/server/watch-jobs.test.ts +173 -0
  259. package/src/lib/server/watch-jobs.ts +532 -0
  260. package/src/lib/server/ws-hub.ts +5 -3
  261. package/src/lib/setup-defaults.ts +352 -11
  262. package/src/lib/tool-definitions.ts +3 -4
  263. package/src/lib/validation/schemas.test.ts +26 -0
  264. package/src/lib/validation/schemas.ts +62 -1
  265. package/src/lib/ws-client.ts +14 -12
  266. package/src/proxy.ts +5 -5
  267. package/src/stores/use-app-store.ts +43 -7
  268. package/src/stores/use-chat-store.ts +31 -2
  269. package/src/stores/use-chatroom-store.ts +153 -26
  270. package/src/types/index.ts +470 -44
  271. package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
  272. package/src/components/chat/new-chat-sheet.tsx +0 -253
  273. package/src/lib/server/main-session.ts +0 -17
  274. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -0,0 +1,230 @@
1
+ import { createHash } from 'crypto'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import type { Agent, MemoryEntry, MemoryReference, Session } from '@/types'
5
+ import { getMemoryDb } from './memory-db'
6
+ import { loadAgents, loadSessions, saveSessions } from './storage'
7
+ import { DATA_DIR } from './data-dir'
8
+
9
+ const MAX_ARCHIVE_MESSAGES = 36
10
+ const MAX_ARCHIVE_LINE_CHARS = 320
11
+ const SESSION_ARCHIVE_EXPORT_DIR = path.join(DATA_DIR, 'session-archives')
12
+
13
+ function toOneLine(value: unknown, maxChars: number): string {
14
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxChars)
15
+ }
16
+
17
+ function messageSpeaker(session: Session, agent: Partial<Agent> | null | undefined, message: Session['messages'][number]): string {
18
+ if (message.role === 'assistant') return agent?.name || 'assistant'
19
+ return session.connectorContext?.senderName || session.user || 'user'
20
+ }
21
+
22
+ function slugifySegment(value: string, fallback: string): string {
23
+ const normalized = value
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9._-]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ return normalized || fallback
28
+ }
29
+
30
+ export function buildSessionArchivePayload(
31
+ session: Session,
32
+ agent?: Partial<Agent> | null,
33
+ ): {
34
+ title: string
35
+ content: string
36
+ metadata: Record<string, unknown>
37
+ references: MemoryReference[]
38
+ hash: string
39
+ } | null {
40
+ if (!Array.isArray(session.messages) || session.messages.length < 2) return null
41
+
42
+ const excerpt = session.messages.slice(-MAX_ARCHIVE_MESSAGES).map((message) => {
43
+ const speaker = messageSpeaker(session, agent, message)
44
+ const kind = message.kind && message.kind !== 'chat' ? ` [${message.kind}]` : ''
45
+ const text = toOneLine(message.text, MAX_ARCHIVE_LINE_CHARS)
46
+ const tools = Array.isArray(message.toolEvents) && message.toolEvents.length > 0
47
+ ? ` | tools=${message.toolEvents.map((event) => event.name).join(',')}`
48
+ : ''
49
+ return `- ${speaker}${kind}: ${text}${tools}`
50
+ }).join('\n')
51
+
52
+ const title = `Session archive: ${session.name || session.id}`
53
+ const content = [
54
+ `session_id: ${session.id}`,
55
+ `session_name: ${toOneLine(session.name, 160)}`,
56
+ `session_type: ${toOneLine(session.sessionType || 'human', 32)}`,
57
+ `agent_name: ${toOneLine(agent?.name || '', 80)}`,
58
+ `last_active_iso: ${new Date(session.lastActiveAt || Date.now()).toISOString()}`,
59
+ `message_count: ${session.messages.length}`,
60
+ session.identityState?.personaLabel ? `persona_label: ${toOneLine(session.identityState.personaLabel, 120)}` : '',
61
+ '',
62
+ 'Transcript excerpt:',
63
+ excerpt,
64
+ ].filter(Boolean).join('\n')
65
+
66
+ const hash = createHash('sha256').update(`${title}\n${content}`).digest('hex').slice(0, 16)
67
+ return {
68
+ title,
69
+ content,
70
+ metadata: {
71
+ tier: 'archive',
72
+ archiveHash: hash,
73
+ sessionName: session.name,
74
+ sessionType: session.sessionType || 'human',
75
+ messageCount: session.messages.length,
76
+ lastActiveAt: session.lastActiveAt || Date.now(),
77
+ personaLabel: session.identityState?.personaLabel || null,
78
+ },
79
+ references: [{
80
+ type: 'session',
81
+ path: session.id,
82
+ title: session.name,
83
+ note: 'Searchable session archive snapshot',
84
+ timestamp: Date.now(),
85
+ }],
86
+ hash,
87
+ }
88
+ }
89
+
90
+ export function buildSessionArchiveMarkdown(
91
+ session: Session,
92
+ payload: NonNullable<ReturnType<typeof buildSessionArchivePayload>>,
93
+ agent?: Partial<Agent> | null,
94
+ ): string {
95
+ const transcriptLines = session.messages.slice(-MAX_ARCHIVE_MESSAGES).map((message) => {
96
+ const speaker = messageSpeaker(session, agent, message)
97
+ const kind = message.kind && message.kind !== 'chat' ? ` (${message.kind})` : ''
98
+ const toolSummary = Array.isArray(message.toolEvents) && message.toolEvents.length > 0
99
+ ? ` [tools: ${message.toolEvents.map((event) => event.name).join(', ')}]`
100
+ : ''
101
+ return `- **${speaker}**${kind}: ${toOneLine(message.text, MAX_ARCHIVE_LINE_CHARS)}${toolSummary}`
102
+ })
103
+
104
+ return [
105
+ `# ${payload.title}`,
106
+ '',
107
+ `- Session ID: ${session.id}`,
108
+ `- Session Name: ${toOneLine(session.name, 160)}`,
109
+ `- Session Type: ${toOneLine(session.sessionType || 'human', 32)}`,
110
+ `- Agent: ${toOneLine(agent?.name || session.agentId || 'unknown', 80)}`,
111
+ `- Last Active: ${new Date(session.lastActiveAt || Date.now()).toISOString()}`,
112
+ `- Messages: ${session.messages.length}`,
113
+ session.identityState?.personaLabel ? `- Persona: ${toOneLine(session.identityState.personaLabel, 120)}` : '',
114
+ '',
115
+ '## Archive Snapshot',
116
+ '',
117
+ '```text',
118
+ payload.content,
119
+ '```',
120
+ '',
121
+ '## Transcript Excerpt',
122
+ '',
123
+ ...transcriptLines,
124
+ '',
125
+ ].filter(Boolean).join('\n')
126
+ }
127
+
128
+ function exportSessionArchiveMarkdown(
129
+ session: Session,
130
+ payload: NonNullable<ReturnType<typeof buildSessionArchivePayload>>,
131
+ agent?: Partial<Agent> | null,
132
+ ): string | null {
133
+ try {
134
+ const agentSegment = slugifySegment(agent?.name || session.agentId || 'shared', 'shared')
135
+ const sessionSegment = slugifySegment(session.name || session.id, session.id)
136
+ const dir = path.join(SESSION_ARCHIVE_EXPORT_DIR, agentSegment)
137
+ fs.mkdirSync(dir, { recursive: true })
138
+ const filePath = path.join(dir, `${sessionSegment}-${session.id}.md`)
139
+ fs.writeFileSync(filePath, buildSessionArchiveMarkdown(session, payload, agent))
140
+ return filePath
141
+ } catch {
142
+ return null
143
+ }
144
+ }
145
+
146
+ export function syncSessionArchiveMemory(
147
+ session: Session,
148
+ opts?: { agent?: Partial<Agent> | null },
149
+ ): { stored: boolean; memoryId?: string; reason?: string } {
150
+ const agent = opts?.agent ?? (session.agentId ? loadAgents()[session.agentId] : null)
151
+ if (!session.agentId && !agent?.id) {
152
+ return { stored: false, reason: 'missing_agent' }
153
+ }
154
+
155
+ const payload = buildSessionArchivePayload(session, agent)
156
+ if (!payload) {
157
+ return { stored: false, reason: 'insufficient_messages' }
158
+ }
159
+
160
+ const memDb = getMemoryDb()
161
+ const existing = memDb.getLatestBySessionCategory(session.id, 'session_archive')
162
+ const existingHash = typeof existing?.metadata?.archiveHash === 'string'
163
+ ? existing.metadata.archiveHash
164
+ : null
165
+ if (session.sessionArchiveState?.lastHash === payload.hash || existingHash === payload.hash) {
166
+ session.sessionArchiveState = {
167
+ memoryId: session.sessionArchiveState?.memoryId || existing?.id || null,
168
+ lastHash: payload.hash,
169
+ lastSyncedAt: session.sessionArchiveState?.lastSyncedAt || existing?.updatedAt || null,
170
+ messageCount: session.messages.length,
171
+ exportPath: session.sessionArchiveState?.exportPath || null,
172
+ }
173
+ return { stored: false, memoryId: existing?.id || session.sessionArchiveState.memoryId || undefined, reason: 'unchanged' }
174
+ }
175
+ const entry: MemoryEntry | null = existing
176
+ ? memDb.update(existing.id, {
177
+ title: payload.title,
178
+ content: payload.content,
179
+ metadata: payload.metadata,
180
+ references: payload.references,
181
+ linkedMemoryIds: existing.linkedMemoryIds,
182
+ })
183
+ : memDb.add({
184
+ agentId: session.agentId || agent?.id || null,
185
+ sessionId: session.id,
186
+ category: 'session_archive',
187
+ title: payload.title,
188
+ content: payload.content,
189
+ metadata: payload.metadata,
190
+ references: payload.references,
191
+ linkedMemoryIds: [],
192
+ })
193
+
194
+ if (!entry) return { stored: false, reason: 'store_failed' }
195
+ const exportPath = exportSessionArchiveMarkdown(session, payload, agent)
196
+
197
+ session.sessionArchiveState = {
198
+ memoryId: entry.id,
199
+ lastHash: payload.hash,
200
+ lastSyncedAt: Date.now(),
201
+ messageCount: session.messages.length,
202
+ exportPath,
203
+ }
204
+
205
+ return { stored: true, memoryId: entry.id }
206
+ }
207
+
208
+ export function syncAllSessionArchiveMemories(): { synced: number; skipped: number; sessionIds: string[] } {
209
+ const sessions = loadSessions()
210
+ const agents = loadAgents()
211
+ let changed = false
212
+ let synced = 0
213
+ let skipped = 0
214
+ const sessionIds: string[] = []
215
+
216
+ for (const session of Object.values(sessions) as Session[]) {
217
+ const agent = session.agentId ? agents[session.agentId] : null
218
+ const result = syncSessionArchiveMemory(session, { agent })
219
+ if (result.stored) {
220
+ synced += 1
221
+ sessionIds.push(session.id)
222
+ changed = true
223
+ } else {
224
+ skipped += 1
225
+ }
226
+ }
227
+
228
+ if (changed) saveSessions(sessions)
229
+ return { synced, skipped, sessionIds }
230
+ }
@@ -1,23 +1,7 @@
1
1
  import { genId } from '@/lib/id'
2
+ import type { MailboxEnvelope } from '@/types'
2
3
  import { loadSessions, saveSessions } from './storage'
3
4
 
4
- export type MailboxStatus = 'new' | 'ack'
5
-
6
- export interface MailboxEnvelope {
7
- id: string
8
- type: string
9
- payload: string
10
- fromSessionId?: string | null
11
- fromAgentId?: string | null
12
- toSessionId: string
13
- toAgentId?: string | null
14
- correlationId?: string | null
15
- status: MailboxStatus
16
- createdAt: number
17
- expiresAt?: number | null
18
- ackAt?: number | null
19
- }
20
-
21
5
  interface MailboxOptions {
22
6
  limit?: number
23
7
  includeAcked?: boolean
@@ -78,6 +62,13 @@ export function sendMailboxEnvelope(input: {
78
62
  target.lastActiveAt = now
79
63
  sessions[input.toSessionId] = target
80
64
  saveSessions(sessions)
65
+ import('./watch-jobs')
66
+ .then(({ triggerMailboxWatchJobs }) => {
67
+ triggerMailboxWatchJobs({ sessionId: input.toSessionId, envelope })
68
+ })
69
+ .catch(() => {
70
+ // best-effort trigger only
71
+ })
81
72
  return envelope
82
73
  }
83
74
 
@@ -126,4 +117,3 @@ export function clearMailbox(sessionId: string, includeAcked = true): { before:
126
117
  saveSessions(sessions)
127
118
  return { before, after: afterList.length }
128
119
  }
129
-
@@ -0,0 +1,99 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import type { Session } from '@/types'
4
+ import {
5
+ evaluateSessionFreshness,
6
+ inferSessionResetType,
7
+ resetSessionRuntime,
8
+ resolveSessionResetPolicy,
9
+ } from './session-reset-policy'
10
+
11
+ function makeSession(overrides: Partial<Session> = {}): Session {
12
+ return {
13
+ id: 's1',
14
+ name: 'Test Session',
15
+ cwd: process.cwd(),
16
+ user: 'user',
17
+ provider: 'openai',
18
+ model: 'gpt-4.1',
19
+ claudeSessionId: null,
20
+ codexThreadId: null,
21
+ opencodeSessionId: null,
22
+ messages: [{ role: 'user', text: 'hello', time: 1 }],
23
+ createdAt: 1,
24
+ lastActiveAt: 1,
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ test('inferSessionResetType distinguishes direct, group, and thread sessions', () => {
30
+ assert.equal(inferSessionResetType(makeSession()), 'direct')
31
+ assert.equal(inferSessionResetType(makeSession({ connectorContext: { isGroup: true } })), 'group')
32
+ assert.equal(inferSessionResetType(makeSession({ connectorContext: { threadId: 'thread-1' } })), 'thread')
33
+ })
34
+
35
+ test('resolveSessionResetPolicy falls back to type defaults', () => {
36
+ const direct = resolveSessionResetPolicy({ session: makeSession() })
37
+ assert.equal(direct.mode, 'idle')
38
+ assert.equal(direct.idleTimeoutSec, 12 * 60 * 60)
39
+
40
+ const thread = resolveSessionResetPolicy({ session: makeSession({ connectorContext: { threadId: 'thread-1' } }) })
41
+ assert.equal(thread.mode, 'idle')
42
+ assert.equal(thread.idleTimeoutSec, 4 * 60 * 60)
43
+ })
44
+
45
+ test('evaluateSessionFreshness expires idle sessions', () => {
46
+ const session = makeSession({ createdAt: 0, lastActiveAt: 0 })
47
+ const policy = resolveSessionResetPolicy({
48
+ session: { ...session, sessionIdleTimeoutSec: 10, sessionMaxAgeSec: 60 },
49
+ })
50
+ const freshness = evaluateSessionFreshness({ session, policy, now: 11_000 })
51
+ assert.deepEqual(freshness.reason, 'idle_timeout:10')
52
+ assert.equal(freshness.fresh, false)
53
+ })
54
+
55
+ test('evaluateSessionFreshness supports daily reset boundaries', () => {
56
+ const session = makeSession({
57
+ createdAt: Date.parse('2026-03-04T00:00:00.000Z'),
58
+ lastActiveAt: Date.parse('2026-03-05T03:30:00.000Z'),
59
+ })
60
+ const policy = resolveSessionResetPolicy({
61
+ session: {
62
+ ...session,
63
+ sessionResetMode: 'daily',
64
+ sessionDailyResetAt: '04:00',
65
+ sessionResetTimezone: 'UTC',
66
+ sessionMaxAgeSec: 999999,
67
+ sessionIdleTimeoutSec: 0,
68
+ },
69
+ })
70
+ const freshness = evaluateSessionFreshness({
71
+ session,
72
+ policy,
73
+ now: Date.parse('2026-03-05T10:00:00.000Z'),
74
+ })
75
+ assert.equal(freshness.fresh, false)
76
+ assert.equal(freshness.reason, 'daily_reset:04:00')
77
+ })
78
+
79
+ test('resetSessionRuntime clears transient state but preserves continuity state', () => {
80
+ const session = makeSession({
81
+ claudeSessionId: 'claude',
82
+ codexThreadId: 'codex',
83
+ opencodeSessionId: 'open',
84
+ delegateResumeIds: { claudeCode: 'a', codex: 'b', opencode: 'c', gemini: 'd' },
85
+ lastHeartbeatText: 'heartbeat',
86
+ lastHeartbeatSentAt: 123,
87
+ lastAutoMemoryAt: 456,
88
+ conversationTone: 'formal',
89
+ identityState: { personaLabel: 'Planner' },
90
+ })
91
+
92
+ const cleared = resetSessionRuntime(session, 'idle_timeout:10', { now: 1000 })
93
+
94
+ assert.equal(cleared, 1)
95
+ assert.deepEqual(session.messages, [])
96
+ assert.equal(session.claudeSessionId, null)
97
+ assert.equal(session.identityState?.personaLabel, 'Planner')
98
+ assert.equal(session.lastSessionResetReason, 'idle_timeout:10')
99
+ })
@@ -0,0 +1,311 @@
1
+ import type { Agent, AppSettings, Session, SessionResetMode, SessionResetType } from '@/types'
2
+
3
+ export interface ResolvedSessionResetPolicy {
4
+ type: SessionResetType
5
+ mode: SessionResetMode
6
+ idleTimeoutSec: number | null
7
+ maxAgeSec: number | null
8
+ dailyResetAt: string | null
9
+ timezone: string | null
10
+ }
11
+
12
+ export interface SessionFreshnessSnapshot {
13
+ fresh: boolean
14
+ reason?: string
15
+ policy: ResolvedSessionResetPolicy
16
+ idleExpiresAt: number | null
17
+ dailyBoundaryKey: string | null
18
+ }
19
+
20
+ const DEFAULT_POLICIES: Record<SessionResetType, ResolvedSessionResetPolicy> = {
21
+ direct: {
22
+ type: 'direct',
23
+ mode: 'idle',
24
+ idleTimeoutSec: 12 * 60 * 60,
25
+ maxAgeSec: 7 * 24 * 60 * 60,
26
+ dailyResetAt: null,
27
+ timezone: null,
28
+ },
29
+ group: {
30
+ type: 'group',
31
+ mode: 'idle',
32
+ idleTimeoutSec: 6 * 60 * 60,
33
+ maxAgeSec: 3 * 24 * 60 * 60,
34
+ dailyResetAt: null,
35
+ timezone: null,
36
+ },
37
+ thread: {
38
+ type: 'thread',
39
+ mode: 'idle',
40
+ idleTimeoutSec: 4 * 60 * 60,
41
+ maxAgeSec: 2 * 24 * 60 * 60,
42
+ dailyResetAt: null,
43
+ timezone: null,
44
+ },
45
+ main: {
46
+ type: 'main',
47
+ mode: 'daily',
48
+ idleTimeoutSec: 24 * 60 * 60,
49
+ maxAgeSec: 14 * 24 * 60 * 60,
50
+ dailyResetAt: '04:00',
51
+ timezone: null,
52
+ },
53
+ }
54
+
55
+ function parseIntBounded(value: unknown, min: number, max: number): number | null {
56
+ if (value === null || value === undefined || value === '') return null
57
+ const parsed = typeof value === 'number'
58
+ ? value
59
+ : typeof value === 'string'
60
+ ? Number.parseInt(value.trim(), 10)
61
+ : Number.NaN
62
+ if (!Number.isFinite(parsed)) return null
63
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
64
+ }
65
+
66
+ function normalizeMode(raw: unknown, fallback: SessionResetMode): SessionResetMode {
67
+ const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
68
+ return value === 'daily' ? 'daily' : value === 'idle' ? 'idle' : fallback
69
+ }
70
+
71
+ function normalizeTimeHHMM(raw: unknown): string | null {
72
+ if (typeof raw !== 'string') return null
73
+ const value = raw.trim()
74
+ const match = value.match(/^(\d{1,2}):(\d{2})$/)
75
+ if (!match) return null
76
+ const hours = Number.parseInt(match[1], 10)
77
+ const minutes = Number.parseInt(match[2], 10)
78
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null
79
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
80
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
81
+ }
82
+
83
+ function normalizeTimezone(raw: unknown): string | null {
84
+ if (typeof raw !== 'string') return null
85
+ const value = raw.trim()
86
+ return value || null
87
+ }
88
+
89
+ function getClockParts(date: Date, timezone?: string | null): { dateKey: string; minutes: number } | null {
90
+ try {
91
+ const formatter = new Intl.DateTimeFormat('en-CA', {
92
+ year: 'numeric',
93
+ month: '2-digit',
94
+ day: '2-digit',
95
+ hour: '2-digit',
96
+ minute: '2-digit',
97
+ hour12: false,
98
+ timeZone: timezone || undefined,
99
+ })
100
+ const parts = formatter.formatToParts(date)
101
+ const year = parts.find((part) => part.type === 'year')?.value
102
+ const month = parts.find((part) => part.type === 'month')?.value
103
+ const day = parts.find((part) => part.type === 'day')?.value
104
+ const hour = Number.parseInt(parts.find((part) => part.type === 'hour')?.value || '', 10)
105
+ const minute = Number.parseInt(parts.find((part) => part.type === 'minute')?.value || '', 10)
106
+ if (!year || !month || !day || !Number.isFinite(hour) || !Number.isFinite(minute)) return null
107
+ return {
108
+ dateKey: `${year}-${month}-${day}`,
109
+ minutes: hour * 60 + minute,
110
+ }
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ function boundaryKeyForNow(now: number, boundaryMinutes: number, timezone?: string | null): string | null {
117
+ const current = getClockParts(new Date(now), timezone)
118
+ if (!current) return null
119
+ if (current.minutes >= boundaryMinutes) return current.dateKey
120
+ const previous = getClockParts(new Date(now - 24 * 60 * 60 * 1000), timezone)
121
+ return previous?.dateKey || null
122
+ }
123
+
124
+ function rawField(
125
+ session: Partial<Session> | null | undefined,
126
+ overrides: Record<string, unknown> | undefined,
127
+ agent: Partial<Agent> | null | undefined,
128
+ settings: Partial<AppSettings> | null | undefined,
129
+ key: 'sessionResetMode' | 'sessionIdleTimeoutSec' | 'sessionMaxAgeSec' | 'sessionDailyResetAt' | 'sessionResetTimezone',
130
+ ): unknown {
131
+ if (session && session[key] !== undefined) return session[key]
132
+ if (overrides && overrides[key] !== undefined) return overrides[key]
133
+ if (agent && agent[key] !== undefined) return agent[key]
134
+ if (settings && settings[key] !== undefined) return settings[key]
135
+ return undefined
136
+ }
137
+
138
+ export function inferSessionResetType(
139
+ session: Partial<Session> | null | undefined,
140
+ opts?: { isGroup?: boolean | null; threadId?: string | null },
141
+ ): SessionResetType {
142
+ if ((session?.sessionType as string | undefined) === 'orchestrated') return 'main'
143
+ const threadId = opts?.threadId ?? session?.connectorContext?.threadId ?? null
144
+ if (threadId) return 'thread'
145
+ const isGroup = opts?.isGroup ?? session?.connectorContext?.isGroup ?? false
146
+ return isGroup ? 'group' : 'direct'
147
+ }
148
+
149
+ export function resolveSessionResetPolicy(params: {
150
+ session?: Partial<Session> | null
151
+ agent?: Partial<Agent> | null
152
+ settings?: Partial<AppSettings> | null
153
+ resetType?: SessionResetType
154
+ overrides?: Record<string, unknown>
155
+ }): ResolvedSessionResetPolicy {
156
+ const type = params.resetType ?? inferSessionResetType(params.session)
157
+ const defaults = DEFAULT_POLICIES[type]
158
+ return {
159
+ type,
160
+ mode: normalizeMode(
161
+ rawField(params.session, params.overrides, params.agent, params.settings, 'sessionResetMode'),
162
+ defaults.mode,
163
+ ),
164
+ idleTimeoutSec: parseIntBounded(
165
+ rawField(params.session, params.overrides, params.agent, params.settings, 'sessionIdleTimeoutSec'),
166
+ 0,
167
+ 180 * 24 * 60 * 60,
168
+ ) ?? defaults.idleTimeoutSec,
169
+ maxAgeSec: parseIntBounded(
170
+ rawField(params.session, params.overrides, params.agent, params.settings, 'sessionMaxAgeSec'),
171
+ 0,
172
+ 365 * 24 * 60 * 60,
173
+ ) ?? defaults.maxAgeSec,
174
+ dailyResetAt: normalizeTimeHHMM(
175
+ rawField(params.session, params.overrides, params.agent, params.settings, 'sessionDailyResetAt'),
176
+ ) ?? defaults.dailyResetAt,
177
+ timezone: normalizeTimezone(
178
+ rawField(params.session, params.overrides, params.agent, params.settings, 'sessionResetTimezone'),
179
+ ) ?? defaults.timezone,
180
+ }
181
+ }
182
+
183
+ export function evaluateSessionFreshness(params: {
184
+ session?: Partial<Session> | null
185
+ policy: ResolvedSessionResetPolicy
186
+ now?: number
187
+ }): SessionFreshnessSnapshot {
188
+ const now = typeof params.now === 'number' ? params.now : Date.now()
189
+ const session = params.session
190
+ const policy = params.policy
191
+ const messageCount = Array.isArray(session?.messages) ? session.messages.length : 0
192
+ const createdAt = typeof session?.createdAt === 'number' ? session.createdAt : now
193
+ const lastActiveAt = typeof session?.lastActiveAt === 'number' ? session.lastActiveAt : createdAt
194
+ const idleExpiresAt = typeof policy.idleTimeoutSec === 'number' && policy.idleTimeoutSec > 0
195
+ ? lastActiveAt + policy.idleTimeoutSec * 1000
196
+ : null
197
+
198
+ if (!session || messageCount === 0) {
199
+ return {
200
+ fresh: true,
201
+ policy,
202
+ idleExpiresAt,
203
+ dailyBoundaryKey: null,
204
+ }
205
+ }
206
+
207
+ if (idleExpiresAt !== null && now > idleExpiresAt) {
208
+ return {
209
+ fresh: false,
210
+ reason: `idle_timeout:${policy.idleTimeoutSec}`,
211
+ policy,
212
+ idleExpiresAt,
213
+ dailyBoundaryKey: null,
214
+ }
215
+ }
216
+
217
+ if (typeof policy.maxAgeSec === 'number' && policy.maxAgeSec > 0) {
218
+ const maxAgeMs = policy.maxAgeSec * 1000
219
+ if (now - createdAt > maxAgeMs) {
220
+ return {
221
+ fresh: false,
222
+ reason: `max_age:${policy.maxAgeSec}`,
223
+ policy,
224
+ idleExpiresAt,
225
+ dailyBoundaryKey: null,
226
+ }
227
+ }
228
+ }
229
+
230
+ if (policy.mode === 'daily' && policy.dailyResetAt) {
231
+ const boundary = normalizeTimeHHMM(policy.dailyResetAt)
232
+ if (boundary) {
233
+ const [hours, minutes] = boundary.split(':').map((value) => Number.parseInt(value, 10))
234
+ const boundaryMinutes = hours * 60 + minutes
235
+ const nowBoundaryKey = boundaryKeyForNow(now, boundaryMinutes, policy.timezone)
236
+ const lastActiveParts = getClockParts(new Date(lastActiveAt), policy.timezone)
237
+ if (
238
+ nowBoundaryKey
239
+ && lastActiveParts
240
+ && (
241
+ lastActiveParts.dateKey < nowBoundaryKey
242
+ || (lastActiveParts.dateKey === nowBoundaryKey && lastActiveParts.minutes < boundaryMinutes)
243
+ )
244
+ ) {
245
+ return {
246
+ fresh: false,
247
+ reason: `daily_reset:${policy.dailyResetAt}`,
248
+ policy,
249
+ idleExpiresAt,
250
+ dailyBoundaryKey: nowBoundaryKey,
251
+ }
252
+ }
253
+ return {
254
+ fresh: true,
255
+ policy,
256
+ idleExpiresAt,
257
+ dailyBoundaryKey: nowBoundaryKey,
258
+ }
259
+ }
260
+ }
261
+
262
+ return {
263
+ fresh: true,
264
+ policy,
265
+ idleExpiresAt,
266
+ dailyBoundaryKey: null,
267
+ }
268
+ }
269
+
270
+ export function resetSessionRuntime(
271
+ session: Session,
272
+ reason: string,
273
+ opts?: { now?: number },
274
+ ): number {
275
+ const now = typeof opts?.now === 'number' ? opts.now : Date.now()
276
+ const cleared = Array.isArray(session.messages) ? session.messages.length : 0
277
+
278
+ session.messages = []
279
+ session.claudeSessionId = null
280
+ session.codexThreadId = null
281
+ session.opencodeSessionId = null
282
+ session.delegateResumeIds = {
283
+ claudeCode: null,
284
+ codex: null,
285
+ opencode: null,
286
+ gemini: null,
287
+ }
288
+ session.createdAt = now
289
+ session.lastActiveAt = now
290
+ session.lastAutoMemoryAt = null
291
+ session.lastHeartbeatText = null
292
+ session.lastHeartbeatSentAt = null
293
+ session.conversationTone = undefined
294
+ session.lastSessionResetAt = now
295
+ session.lastSessionResetReason = reason
296
+
297
+ if (session.connectorContext) {
298
+ session.connectorContext = {
299
+ ...session.connectorContext,
300
+ lastResetAt: now,
301
+ lastResetReason: reason,
302
+ lastInboundMessageId: null,
303
+ lastInboundReplyToMessageId: null,
304
+ lastInboundThreadId: null,
305
+ lastOutboundMessageId: null,
306
+ lastOutboundAt: null,
307
+ }
308
+ }
309
+
310
+ return cleared
311
+ }