@swarmclawai/swarmclaw 0.7.2 → 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 (197) hide show
  1. package/README.md +81 -22
  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 +36 -7
  5. package/src/app/api/agents/route.ts +12 -1
  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/chats/[id]/browser/route.ts +5 -1
  9. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  10. package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
  11. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  12. package/src/app/api/chats/[id]/route.ts +18 -0
  13. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  14. package/src/app/api/chats/route.ts +16 -0
  15. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  16. package/src/app/api/connectors/doctor/route.ts +13 -0
  17. package/src/app/api/files/open/route.ts +16 -14
  18. package/src/app/api/memory/maintenance/route.ts +11 -1
  19. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  20. package/src/app/api/openclaw/skills/route.ts +11 -3
  21. package/src/app/api/plugins/dependencies/route.ts +24 -0
  22. package/src/app/api/plugins/install/route.ts +15 -92
  23. package/src/app/api/plugins/route.ts +3 -26
  24. package/src/app/api/plugins/settings/route.ts +17 -12
  25. package/src/app/api/plugins/ui/route.ts +1 -0
  26. package/src/app/api/settings/route.ts +49 -7
  27. package/src/app/api/tasks/[id]/route.ts +15 -6
  28. package/src/app/api/tasks/bulk/route.ts +2 -2
  29. package/src/app/api/tasks/route.ts +9 -4
  30. package/src/app/api/webhooks/[id]/route.ts +8 -1
  31. package/src/app/page.tsx +9 -2
  32. package/src/cli/index.js +4 -0
  33. package/src/cli/index.ts +3 -10
  34. package/src/components/agents/agent-card.tsx +15 -12
  35. package/src/components/agents/agent-chat-list.tsx +101 -1
  36. package/src/components/agents/agent-list.tsx +46 -9
  37. package/src/components/agents/agent-sheet.tsx +207 -16
  38. package/src/components/agents/inspector-panel.tsx +108 -48
  39. package/src/components/auth/access-key-gate.tsx +36 -97
  40. package/src/components/chat/chat-area.tsx +29 -13
  41. package/src/components/chat/chat-card.tsx +4 -20
  42. package/src/components/chat/chat-header.tsx +255 -353
  43. package/src/components/chat/chat-list.tsx +7 -9
  44. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  45. package/src/components/chat/message-list.tsx +3 -1
  46. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  47. package/src/components/connectors/connector-list.tsx +265 -127
  48. package/src/components/connectors/connector-sheet.tsx +217 -0
  49. package/src/components/home/home-view.tsx +128 -4
  50. package/src/components/layout/app-layout.tsx +383 -194
  51. package/src/components/layout/mobile-header.tsx +26 -8
  52. package/src/components/plugins/plugin-list.tsx +15 -3
  53. package/src/components/plugins/plugin-sheet.tsx +118 -9
  54. package/src/components/projects/project-detail.tsx +183 -0
  55. package/src/components/shared/agent-picker-list.tsx +2 -2
  56. package/src/components/shared/command-palette.tsx +111 -24
  57. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  58. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  59. package/src/components/shared/settings/section-heartbeat.tsx +77 -0
  60. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  61. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  62. package/src/components/shared/settings/section-secrets.tsx +6 -6
  63. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  64. package/src/components/shared/settings/section-voice.tsx +5 -1
  65. package/src/components/shared/settings/section-web-search.tsx +10 -2
  66. package/src/components/shared/settings/settings-page.tsx +245 -46
  67. package/src/components/tasks/approvals-panel.tsx +205 -18
  68. package/src/components/tasks/task-board.tsx +242 -46
  69. package/src/components/usage/metrics-dashboard.tsx +74 -1
  70. package/src/components/wallets/wallet-panel.tsx +17 -5
  71. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  72. package/src/lib/auth.ts +17 -0
  73. package/src/lib/chat-streaming-state.test.ts +108 -0
  74. package/src/lib/chat-streaming-state.ts +108 -0
  75. package/src/lib/openclaw-agent-id.test.ts +14 -0
  76. package/src/lib/openclaw-agent-id.ts +31 -0
  77. package/src/lib/server/agent-assignment.test.ts +112 -0
  78. package/src/lib/server/agent-assignment.ts +169 -0
  79. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  80. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  81. package/src/lib/server/approvals.ts +483 -75
  82. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  83. package/src/lib/server/browser-state.test.ts +118 -0
  84. package/src/lib/server/browser-state.ts +123 -0
  85. package/src/lib/server/build-llm.test.ts +36 -0
  86. package/src/lib/server/build-llm.ts +11 -4
  87. package/src/lib/server/builtin-plugins.ts +34 -0
  88. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  89. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  90. package/src/lib/server/chat-execution.ts +250 -61
  91. package/src/lib/server/chatroom-health.test.ts +26 -0
  92. package/src/lib/server/chatroom-health.ts +2 -3
  93. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  94. package/src/lib/server/chatroom-helpers.ts +45 -5
  95. package/src/lib/server/connectors/discord.ts +175 -11
  96. package/src/lib/server/connectors/doctor.test.ts +80 -0
  97. package/src/lib/server/connectors/doctor.ts +116 -0
  98. package/src/lib/server/connectors/manager.ts +946 -110
  99. package/src/lib/server/connectors/policy.test.ts +222 -0
  100. package/src/lib/server/connectors/policy.ts +452 -0
  101. package/src/lib/server/connectors/slack.ts +188 -9
  102. package/src/lib/server/connectors/telegram.ts +65 -15
  103. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  104. package/src/lib/server/connectors/thread-context.ts +72 -0
  105. package/src/lib/server/connectors/types.ts +41 -11
  106. package/src/lib/server/daemon-state.ts +59 -1
  107. package/src/lib/server/data-dir.ts +13 -0
  108. package/src/lib/server/delegation-jobs.test.ts +140 -0
  109. package/src/lib/server/delegation-jobs.ts +248 -0
  110. package/src/lib/server/document-utils.test.ts +47 -0
  111. package/src/lib/server/document-utils.ts +397 -0
  112. package/src/lib/server/heartbeat-service.ts +13 -39
  113. package/src/lib/server/heartbeat-source.test.ts +22 -0
  114. package/src/lib/server/heartbeat-source.ts +7 -0
  115. package/src/lib/server/identity-continuity.test.ts +77 -0
  116. package/src/lib/server/identity-continuity.ts +127 -0
  117. package/src/lib/server/mailbox-utils.ts +347 -0
  118. package/src/lib/server/main-agent-loop.ts +27 -967
  119. package/src/lib/server/memory-db.ts +4 -6
  120. package/src/lib/server/memory-tiers.ts +40 -0
  121. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  122. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  123. package/src/lib/server/openclaw-exec-config.ts +5 -6
  124. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  125. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  126. package/src/lib/server/openclaw-sync.ts +3 -2
  127. package/src/lib/server/orchestrator-lg.ts +17 -6
  128. package/src/lib/server/orchestrator.ts +2 -2
  129. package/src/lib/server/playwright-proxy.mjs +27 -3
  130. package/src/lib/server/plugins.test.ts +207 -0
  131. package/src/lib/server/plugins.ts +822 -69
  132. package/src/lib/server/provider-health.ts +33 -3
  133. package/src/lib/server/queue.ts +3 -20
  134. package/src/lib/server/scheduler.ts +2 -0
  135. package/src/lib/server/session-archive-memory.test.ts +85 -0
  136. package/src/lib/server/session-archive-memory.ts +230 -0
  137. package/src/lib/server/session-mailbox.ts +8 -18
  138. package/src/lib/server/session-reset-policy.test.ts +99 -0
  139. package/src/lib/server/session-reset-policy.ts +311 -0
  140. package/src/lib/server/session-run-manager.ts +33 -80
  141. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  142. package/src/lib/server/session-tools/calendar.ts +2 -12
  143. package/src/lib/server/session-tools/connector.ts +109 -8
  144. package/src/lib/server/session-tools/context.ts +14 -2
  145. package/src/lib/server/session-tools/crawl.ts +447 -0
  146. package/src/lib/server/session-tools/crud.ts +70 -32
  147. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  148. package/src/lib/server/session-tools/delegate.ts +406 -20
  149. package/src/lib/server/session-tools/discovery.ts +22 -4
  150. package/src/lib/server/session-tools/document.ts +283 -0
  151. package/src/lib/server/session-tools/email.ts +1 -3
  152. package/src/lib/server/session-tools/extract.ts +137 -0
  153. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  154. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  155. package/src/lib/server/session-tools/file.ts +237 -24
  156. package/src/lib/server/session-tools/human-loop.ts +227 -0
  157. package/src/lib/server/session-tools/image-gen.ts +1 -3
  158. package/src/lib/server/session-tools/index.ts +56 -1
  159. package/src/lib/server/session-tools/mailbox.ts +276 -0
  160. package/src/lib/server/session-tools/memory.ts +35 -3
  161. package/src/lib/server/session-tools/monitor.ts +150 -7
  162. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  163. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  164. package/src/lib/server/session-tools/platform.ts +142 -4
  165. package/src/lib/server/session-tools/plugin-creator.ts +86 -23
  166. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  167. package/src/lib/server/session-tools/replicate.ts +1 -3
  168. package/src/lib/server/session-tools/schedule.ts +20 -10
  169. package/src/lib/server/session-tools/session-info.ts +36 -3
  170. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  171. package/src/lib/server/session-tools/subagent.ts +193 -27
  172. package/src/lib/server/session-tools/table.ts +587 -0
  173. package/src/lib/server/session-tools/wallet.ts +13 -10
  174. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  175. package/src/lib/server/session-tools/web.ts +896 -100
  176. package/src/lib/server/storage.ts +226 -7
  177. package/src/lib/server/stream-agent-chat.ts +46 -21
  178. package/src/lib/server/structured-extract.test.ts +72 -0
  179. package/src/lib/server/structured-extract.ts +373 -0
  180. package/src/lib/server/task-mention.test.ts +16 -2
  181. package/src/lib/server/task-mention.ts +61 -10
  182. package/src/lib/server/tool-aliases.ts +44 -7
  183. package/src/lib/server/tool-capability-policy.ts +6 -0
  184. package/src/lib/server/tool-retry.ts +2 -0
  185. package/src/lib/server/watch-jobs.test.ts +173 -0
  186. package/src/lib/server/watch-jobs.ts +532 -0
  187. package/src/lib/server/ws-hub.ts +5 -3
  188. package/src/lib/validation/schemas.test.ts +26 -0
  189. package/src/lib/validation/schemas.ts +7 -0
  190. package/src/lib/ws-client.ts +14 -12
  191. package/src/proxy.ts +5 -5
  192. package/src/stores/use-app-store.ts +0 -6
  193. package/src/stores/use-chat-store.ts +31 -2
  194. package/src/types/index.ts +287 -44
  195. package/src/components/chat/new-chat-sheet.tsx +0 -253
  196. package/src/lib/server/main-session.ts +0 -17
  197. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -15,6 +15,7 @@ const states: Map<string, ProviderHealthState> =
15
15
  (globalThis as any)[gk] ?? ((globalThis as any)[gk] = new Map<string, ProviderHealthState>())
16
16
 
17
17
  const cliCheckCache = new Map<string, { at: number; ok: boolean }>()
18
+ const delegateReadyCache = new Map<string, { at: number; ok: boolean }>()
18
19
  const CLI_CHECK_TTL_MS = 30_000
19
20
 
20
21
  function commandExists(binary: string): boolean {
@@ -70,6 +71,35 @@ function delegateBinary(delegateTool: DelegateTool): string {
70
71
  return 'opencode'
71
72
  }
72
73
 
74
+ function delegateToolReady(delegateTool: DelegateTool): boolean {
75
+ const now = Date.now()
76
+ const cached = delegateReadyCache.get(delegateTool)
77
+ if (cached && now - cached.at < CLI_CHECK_TTL_MS) return cached.ok
78
+
79
+ const binary = delegateBinary(delegateTool)
80
+ let ok = commandExists(binary)
81
+ if (ok && delegateTool === 'delegate_to_claude_code') {
82
+ const probe = spawnSync(binary, ['auth', 'status'], { encoding: 'utf-8', timeout: 8000 })
83
+ if ((probe.status ?? 1) !== 0) {
84
+ let loggedIn = false
85
+ try {
86
+ const parsed = JSON.parse(probe.stdout || '{}') as { loggedIn?: boolean }
87
+ loggedIn = parsed.loggedIn === true
88
+ } catch {
89
+ loggedIn = false
90
+ }
91
+ ok = loggedIn
92
+ }
93
+ } else if (ok && delegateTool === 'delegate_to_codex_cli') {
94
+ const probe = spawnSync(binary, ['login', 'status'], { encoding: 'utf-8', timeout: 8000 })
95
+ const probeText = `${probe.stdout || ''}\n${probe.stderr || ''}`.toLowerCase()
96
+ ok = (probe.status ?? 1) === 0 && probeText.includes('logged in')
97
+ }
98
+
99
+ delegateReadyCache.set(delegateTool, { at: now, ok })
100
+ return ok
101
+ }
102
+
73
103
  function delegateProviderId(delegateTool: DelegateTool): string {
74
104
  if (delegateTool === 'delegate_to_claude_code') return 'claude-cli'
75
105
  if (delegateTool === 'delegate_to_codex_cli') return 'codex-cli'
@@ -85,9 +115,9 @@ export function rankDelegatesByHealth(order: DelegateTool[]): DelegateTool[] {
85
115
  return true
86
116
  })
87
117
  return deduped.sort((a, b) => {
88
- const aBinOk = commandExists(delegateBinary(a))
89
- const bBinOk = commandExists(delegateBinary(b))
90
- if (aBinOk !== bBinOk) return aBinOk ? -1 : 1
118
+ const aReady = delegateToolReady(a)
119
+ const bReady = delegateToolReady(b)
120
+ if (aReady !== bReady) return aReady ? -1 : 1
91
121
 
92
122
  const aCool = isProviderCoolingDown(delegateProviderId(a))
93
123
  const bCool = isProviderCoolingDown(delegateProviderId(b))
@@ -11,7 +11,6 @@ import { pushMainLoopEventToMainSessions } from './main-agent-loop'
11
11
  import { executeSessionChatTurn } from './chat-execution'
12
12
  import { extractTaskResult, formatResultBody } from './task-result'
13
13
  import { getCheckpointSaver } from './langgraph-checkpoint'
14
- import { isMainLoopSession } from './main-session'
15
14
  import { cascadeUnblock } from './dag-validation'
16
15
  import { performGuardianRollback } from './guardian'
17
16
  import type { Agent, BoardTask, Connector, Message } from '@/types'
@@ -282,10 +281,6 @@ function pushQueueUnique(queue: string[], id: string): void {
282
281
  if (!queueContains(queue, id)) queue.push(id)
283
282
  }
284
283
 
285
- function isMainSession(session: SessionLike | null | undefined): boolean {
286
- return isMainLoopSession(session)
287
- }
288
-
289
284
  function resolveTaskOwnerUser(task: ScheduleTaskMeta, sessions: Record<string, SessionLike>): string | null {
290
285
  const direct = typeof task.user === 'string' ? task.user.trim() : ''
291
286
  if (direct) return direct
@@ -594,18 +589,7 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
594
589
  return msg
595
590
  }
596
591
 
597
- for (const session of Object.values(sessions) as SessionLike[]) {
598
- if (!isMainSession(session)) continue
599
- if (ownerUser && session?.user && session.user !== ownerUser) continue
600
- const last = Array.isArray(session.messages) ? session.messages.at(-1) : null
601
- if (last?.role === 'assistant' && last?.text === body && typeof last?.time === 'number' && now - last.time < 30_000) continue
602
- if (!Array.isArray(session.messages)) session.messages = []
603
- session.messages.push(buildMsg())
604
- session.lastActiveAt = now
605
- changed = true
606
- }
607
-
608
- // Also push to the agent's persistent thread session
592
+ // Push to the agent's shortcut chat session.
609
593
  try {
610
594
  const agents = loadAgents()
611
595
  const agent = agents[task.agentId]
@@ -830,13 +814,12 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
830
814
  }
831
815
 
832
816
  // Push to delegating agent's active user-facing chat sessions
833
- // so the result is visible in the chat the user is looking at
817
+ // so the result is visible in the chat the user is looking at.
834
818
  if (delegator) {
835
819
  for (const session of Object.values(sessions)) {
836
820
  if (!session || session.agentId !== delegatedBy) continue
837
- // Skip thread sessions and orchestrated/subagent sessions
821
+ // Skip the agent shortcut session itself.
838
822
  if (session.id === delegator.threadSessionId) continue
839
- if (session.sessionType === 'orchestrated') continue
840
823
  // Only push to recently-active sessions (within last 30 minutes)
841
824
  const lastActive = typeof session.lastActiveAt === 'number' ? session.lastActiveAt : 0
842
825
  if (now - lastActive > 30 * 60_000) continue
@@ -6,6 +6,7 @@ import { pushMainLoopEventToMainSessions } from './main-agent-loop'
6
6
  import { getScheduleSignatureKey } from '@/lib/schedule-dedupe'
7
7
  import { enqueueSystemEvent } from './system-events'
8
8
  import { requestHeartbeatNow } from './heartbeat-wake'
9
+ import { processDueWatchJobs } from './watch-jobs'
9
10
 
10
11
  const TICK_INTERVAL = 60_000 // 60 seconds
11
12
  let intervalId: ReturnType<typeof setInterval> | null = null
@@ -73,6 +74,7 @@ function computeNextRuns() {
73
74
 
74
75
  async function tick() {
75
76
  const now = Date.now()
77
+ await processDueWatchJobs(now)
76
78
  const schedules = loadSchedules()
77
79
  const agents = loadAgents()
78
80
  const tasks = loadTasks()
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import type { Session } from '@/types'
4
+ import { buildSessionArchiveMarkdown, buildSessionArchivePayload } from './session-archive-memory'
5
+
6
+ test('buildSessionArchivePayload summarizes session transcript and metadata', () => {
7
+ const session = {
8
+ id: 'session-1',
9
+ name: 'Support Thread',
10
+ cwd: process.cwd(),
11
+ user: 'Alice',
12
+ provider: 'openai',
13
+ model: 'gpt-4.1',
14
+ claudeSessionId: null,
15
+ codexThreadId: null,
16
+ opencodeSessionId: null,
17
+ createdAt: Date.parse('2026-03-05T00:00:00.000Z'),
18
+ lastActiveAt: Date.parse('2026-03-05T10:00:00.000Z'),
19
+ sessionType: 'human',
20
+ messages: [
21
+ { role: 'user', text: 'Can you help me debug this issue?', time: 1 },
22
+ { role: 'assistant', text: 'Yes, show me the stack trace.', time: 2, toolEvents: [{ name: 'files', input: '{}' }] },
23
+ ],
24
+ identityState: { personaLabel: 'Debugger' },
25
+ } as Session
26
+
27
+ const payload = buildSessionArchivePayload(session, { name: 'Swarmy' })
28
+
29
+ assert.ok(payload)
30
+ assert.equal(payload?.title, 'Session archive: Support Thread')
31
+ assert.match(payload?.content || '', /Transcript excerpt:/)
32
+ assert.match(payload?.content || '', /Swarmy/)
33
+ assert.equal(payload?.metadata.tier, 'archive')
34
+ assert.equal(payload?.references[0]?.type, 'session')
35
+ })
36
+
37
+ test('buildSessionArchiveMarkdown creates a portable markdown snapshot', () => {
38
+ const session = {
39
+ id: 'session-3',
40
+ name: 'Architecture Review',
41
+ cwd: process.cwd(),
42
+ user: 'Alice',
43
+ provider: 'openai',
44
+ model: 'gpt-4.1',
45
+ claudeSessionId: null,
46
+ codexThreadId: null,
47
+ opencodeSessionId: null,
48
+ createdAt: Date.parse('2026-03-05T00:00:00.000Z'),
49
+ lastActiveAt: Date.parse('2026-03-05T10:00:00.000Z'),
50
+ sessionType: 'human',
51
+ messages: [
52
+ { role: 'user', text: 'Summarize the new connector policy.', time: 1 },
53
+ { role: 'assistant', text: 'It now uses scoped sessions and freshness resets.', time: 2 },
54
+ ],
55
+ identityState: { personaLabel: 'Reviewer' },
56
+ } as Session
57
+
58
+ const payload = buildSessionArchivePayload(session, { name: 'Swarmy' })
59
+ assert.ok(payload)
60
+
61
+ const markdown = buildSessionArchiveMarkdown(session, payload!, { name: 'Swarmy' })
62
+ assert.match(markdown, /^# Session archive: Architecture Review/m)
63
+ assert.match(markdown, /## Archive Snapshot/)
64
+ assert.match(markdown, /## Transcript Excerpt/)
65
+ assert.match(markdown, /\*\*Swarmy\*\*/)
66
+ })
67
+
68
+ test('buildSessionArchivePayload skips trivial sessions', () => {
69
+ const session = {
70
+ id: 'session-2',
71
+ name: 'Too Short',
72
+ cwd: process.cwd(),
73
+ user: 'Bob',
74
+ provider: 'openai',
75
+ model: 'gpt-4.1',
76
+ claudeSessionId: null,
77
+ codexThreadId: null,
78
+ opencodeSessionId: null,
79
+ createdAt: 1,
80
+ lastActiveAt: 1,
81
+ messages: [{ role: 'user', text: 'hi', time: 1 }],
82
+ } as Session
83
+
84
+ assert.equal(buildSessionArchivePayload(session), null)
85
+ })
@@ -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
+ })