@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.
- package/README.md +81 -22
- package/package.json +1 -1
- package/src/app/api/agents/[id]/route.ts +26 -0
- package/src/app/api/agents/[id]/thread/route.ts +36 -7
- package/src/app/api/agents/route.ts +12 -1
- package/src/app/api/auth/route.ts +76 -7
- package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
- package/src/app/api/chats/[id]/browser/route.ts +5 -1
- package/src/app/api/chats/[id]/chat/route.ts +7 -3
- package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
- package/src/app/api/chats/[id]/messages/route.ts +19 -13
- package/src/app/api/chats/[id]/route.ts +18 -0
- package/src/app/api/chats/[id]/stop/route.ts +6 -1
- package/src/app/api/chats/route.ts +16 -0
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
- package/src/app/api/connectors/doctor/route.ts +13 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/memory/maintenance/route.ts +11 -1
- package/src/app/api/openclaw/agent-files/route.ts +27 -4
- package/src/app/api/openclaw/skills/route.ts +11 -3
- package/src/app/api/plugins/dependencies/route.ts +24 -0
- package/src/app/api/plugins/install/route.ts +15 -92
- package/src/app/api/plugins/route.ts +3 -26
- package/src/app/api/plugins/settings/route.ts +17 -12
- package/src/app/api/plugins/ui/route.ts +1 -0
- package/src/app/api/settings/route.ts +49 -7
- package/src/app/api/tasks/[id]/route.ts +15 -6
- package/src/app/api/tasks/bulk/route.ts +2 -2
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +9 -2
- package/src/cli/index.js +4 -0
- package/src/cli/index.ts +3 -10
- package/src/components/agents/agent-card.tsx +15 -12
- package/src/components/agents/agent-chat-list.tsx +101 -1
- package/src/components/agents/agent-list.tsx +46 -9
- package/src/components/agents/agent-sheet.tsx +207 -16
- package/src/components/agents/inspector-panel.tsx +108 -48
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/chat/chat-area.tsx +29 -13
- package/src/components/chat/chat-card.tsx +4 -20
- package/src/components/chat/chat-header.tsx +255 -353
- package/src/components/chat/chat-list.tsx +7 -9
- package/src/components/chat/checkpoint-timeline.tsx +1 -1
- package/src/components/chat/message-list.tsx +3 -1
- package/src/components/chatrooms/chatroom-view.tsx +347 -205
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +217 -0
- package/src/components/home/home-view.tsx +128 -4
- package/src/components/layout/app-layout.tsx +383 -194
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/plugins/plugin-list.tsx +15 -3
- package/src/components/plugins/plugin-sheet.tsx +118 -9
- package/src/components/projects/project-detail.tsx +183 -0
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/command-palette.tsx +111 -24
- package/src/components/shared/settings/plugin-manager.tsx +20 -4
- package/src/components/shared/settings/section-capability-policy.tsx +105 -0
- package/src/components/shared/settings/section-heartbeat.tsx +77 -0
- package/src/components/shared/settings/section-orchestrator.tsx +3 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
- package/src/components/shared/settings/section-secrets.tsx +6 -6
- package/src/components/shared/settings/section-user-preferences.tsx +1 -1
- package/src/components/shared/settings/section-voice.tsx +5 -1
- package/src/components/shared/settings/section-web-search.tsx +10 -2
- package/src/components/shared/settings/settings-page.tsx +245 -46
- package/src/components/tasks/approvals-panel.tsx +205 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/usage/metrics-dashboard.tsx +74 -1
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +7 -7
- package/src/lib/auth.ts +17 -0
- package/src/lib/chat-streaming-state.test.ts +108 -0
- package/src/lib/chat-streaming-state.ts +108 -0
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +205 -0
- package/src/lib/server/approvals.ts +483 -75
- package/src/lib/server/autonomy-runtime.test.ts +341 -0
- package/src/lib/server/browser-state.test.ts +118 -0
- package/src/lib/server/browser-state.ts +123 -0
- package/src/lib/server/build-llm.test.ts +36 -0
- package/src/lib/server/build-llm.ts +11 -4
- package/src/lib/server/builtin-plugins.ts +34 -0
- package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
- package/src/lib/server/chat-execution.ts +250 -61
- package/src/lib/server/chatroom-health.test.ts +26 -0
- package/src/lib/server/chatroom-health.ts +2 -3
- package/src/lib/server/chatroom-helpers.test.ts +67 -2
- package/src/lib/server/chatroom-helpers.ts +45 -5
- package/src/lib/server/connectors/discord.ts +175 -11
- package/src/lib/server/connectors/doctor.test.ts +80 -0
- package/src/lib/server/connectors/doctor.ts +116 -0
- package/src/lib/server/connectors/manager.ts +946 -110
- package/src/lib/server/connectors/policy.test.ts +222 -0
- package/src/lib/server/connectors/policy.ts +452 -0
- package/src/lib/server/connectors/slack.ts +188 -9
- package/src/lib/server/connectors/telegram.ts +65 -15
- package/src/lib/server/connectors/thread-context.test.ts +44 -0
- package/src/lib/server/connectors/thread-context.ts +72 -0
- package/src/lib/server/connectors/types.ts +41 -11
- package/src/lib/server/daemon-state.ts +59 -1
- package/src/lib/server/data-dir.ts +13 -0
- package/src/lib/server/delegation-jobs.test.ts +140 -0
- package/src/lib/server/delegation-jobs.ts +248 -0
- package/src/lib/server/document-utils.test.ts +47 -0
- package/src/lib/server/document-utils.ts +397 -0
- package/src/lib/server/heartbeat-service.ts +13 -39
- package/src/lib/server/heartbeat-source.test.ts +22 -0
- package/src/lib/server/heartbeat-source.ts +7 -0
- package/src/lib/server/identity-continuity.test.ts +77 -0
- package/src/lib/server/identity-continuity.ts +127 -0
- package/src/lib/server/mailbox-utils.ts +347 -0
- package/src/lib/server/main-agent-loop.ts +27 -967
- package/src/lib/server/memory-db.ts +4 -6
- package/src/lib/server/memory-tiers.ts +40 -0
- package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
- package/src/lib/server/openclaw-agent-resolver.ts +128 -0
- package/src/lib/server/openclaw-exec-config.ts +5 -6
- package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
- package/src/lib/server/openclaw-skills-normalize.ts +136 -0
- package/src/lib/server/openclaw-sync.ts +3 -2
- package/src/lib/server/orchestrator-lg.ts +17 -6
- package/src/lib/server/orchestrator.ts +2 -2
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +207 -0
- package/src/lib/server/plugins.ts +822 -69
- package/src/lib/server/provider-health.ts +33 -3
- package/src/lib/server/queue.ts +3 -20
- package/src/lib/server/scheduler.ts +2 -0
- package/src/lib/server/session-archive-memory.test.ts +85 -0
- package/src/lib/server/session-archive-memory.ts +230 -0
- package/src/lib/server/session-mailbox.ts +8 -18
- package/src/lib/server/session-reset-policy.test.ts +99 -0
- package/src/lib/server/session-reset-policy.ts +311 -0
- package/src/lib/server/session-run-manager.ts +33 -80
- package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
- package/src/lib/server/session-tools/calendar.ts +2 -12
- package/src/lib/server/session-tools/connector.ts +109 -8
- package/src/lib/server/session-tools/context.ts +14 -2
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +70 -32
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +406 -20
- package/src/lib/server/session-tools/discovery.ts +22 -4
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/email.ts +1 -3
- package/src/lib/server/session-tools/extract.ts +137 -0
- package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +237 -24
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +1 -3
- package/src/lib/server/session-tools/index.ts +56 -1
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/memory.ts +35 -3
- package/src/lib/server/session-tools/monitor.ts +150 -7
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +142 -4
- package/src/lib/server/session-tools/plugin-creator.ts +86 -23
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +1 -3
- package/src/lib/server/session-tools/schedule.ts +20 -10
- package/src/lib/server/session-tools/session-info.ts +36 -3
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
- package/src/lib/server/session-tools/subagent.ts +193 -27
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +13 -10
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +896 -100
- package/src/lib/server/storage.ts +226 -7
- package/src/lib/server/stream-agent-chat.ts +46 -21
- package/src/lib/server/structured-extract.test.ts +72 -0
- package/src/lib/server/structured-extract.ts +373 -0
- package/src/lib/server/task-mention.test.ts +16 -2
- package/src/lib/server/task-mention.ts +61 -10
- package/src/lib/server/tool-aliases.ts +44 -7
- package/src/lib/server/tool-capability-policy.ts +6 -0
- package/src/lib/server/tool-retry.ts +2 -0
- package/src/lib/server/watch-jobs.test.ts +173 -0
- package/src/lib/server/watch-jobs.ts +532 -0
- package/src/lib/server/ws-hub.ts +5 -3
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +7 -0
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +0 -6
- package/src/stores/use-chat-store.ts +31 -2
- package/src/types/index.ts +287 -44
- package/src/components/chat/new-chat-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -17
- 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
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
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))
|
package/src/lib/server/queue.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
})
|