@swarmclawai/swarmclaw 1.9.13 → 1.9.15
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 +18 -0
- package/package.json +2 -2
- package/src/app/api/chats/[id]/context-pack/route.ts +43 -0
- package/src/app/api/chats/context-pack-route.test.ts +109 -0
- package/src/app/api/runs/[id]/handoff/route.ts +26 -0
- package/src/app/api/runs/run-handoff-route.test.ts +120 -0
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/chat/chat-header.tsx +36 -3
- package/src/components/runs/run-list.tsx +44 -6
- package/src/lib/server/agents/main-agent-loop.test.ts +1 -1
- package/src/lib/server/chats/session-context-pack.test.ts +121 -0
- package/src/lib/server/chats/session-context-pack.ts +387 -0
- package/src/lib/server/memory/memory-abstract.ts +0 -1
- package/src/lib/server/memory/temporal-decay.ts +0 -1
- package/src/lib/server/runs/run-handoff.test.ts +112 -0
- package/src/lib/server/runs/run-handoff.ts +171 -0
- package/src/lib/server/runtime/wake-mode.ts +0 -3
- package/src/lib/server/skills/skill-prompt-budget.ts +2 -2
- package/src/lib/server/workspace-context.ts +0 -3
- package/src/types/index.ts +1 -0
- package/src/types/run-handoff.ts +48 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildSessionContextPack,
|
|
6
|
+
formatSessionContextPackMarkdown,
|
|
7
|
+
} from '@/lib/server/chats/session-context-pack'
|
|
8
|
+
import type { BoardTask, Message, Session } from '@/types'
|
|
9
|
+
|
|
10
|
+
function session(overrides: Partial<Session> = {}): Session {
|
|
11
|
+
return {
|
|
12
|
+
id: 'session-1',
|
|
13
|
+
name: 'Release chat',
|
|
14
|
+
cwd: '/workspace/release',
|
|
15
|
+
user: 'operator',
|
|
16
|
+
provider: 'claude-cli',
|
|
17
|
+
model: 'claude-sonnet-4-6',
|
|
18
|
+
claudeSessionId: null,
|
|
19
|
+
messages: [],
|
|
20
|
+
createdAt: 100,
|
|
21
|
+
lastActiveAt: 200,
|
|
22
|
+
agentId: 'agent-1',
|
|
23
|
+
tools: ['shell', 'files'],
|
|
24
|
+
extensions: ['release-kit'],
|
|
25
|
+
runContext: {
|
|
26
|
+
objective: 'Ship the next release.',
|
|
27
|
+
constraints: ['Keep public notes concise.'],
|
|
28
|
+
keyFacts: ['npm is already authenticated.'],
|
|
29
|
+
discoveries: ['Desktop packaging is slow.'],
|
|
30
|
+
failedApproaches: [],
|
|
31
|
+
currentPlan: ['Run tests', 'Cut tag'],
|
|
32
|
+
completedSteps: ['Bumped version'],
|
|
33
|
+
blockers: [],
|
|
34
|
+
parentContext: null,
|
|
35
|
+
updatedAt: 250,
|
|
36
|
+
version: 1,
|
|
37
|
+
},
|
|
38
|
+
...overrides,
|
|
39
|
+
} as Session
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function message(overrides: Partial<Message> = {}): Message {
|
|
43
|
+
return {
|
|
44
|
+
role: 'user',
|
|
45
|
+
text: 'Please prepare the release.',
|
|
46
|
+
time: 300,
|
|
47
|
+
...overrides,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function task(overrides: Partial<BoardTask> = {}): BoardTask {
|
|
52
|
+
return {
|
|
53
|
+
id: 'task-1',
|
|
54
|
+
title: 'Release task',
|
|
55
|
+
description: 'Prepare and verify the release.',
|
|
56
|
+
status: 'running',
|
|
57
|
+
agentId: 'agent-1',
|
|
58
|
+
sessionId: 'session-1',
|
|
59
|
+
createdAt: 120,
|
|
60
|
+
updatedAt: 320,
|
|
61
|
+
...overrides,
|
|
62
|
+
} as BoardTask
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('session context packs', () => {
|
|
66
|
+
it('builds a concise pack with linked tasks, attachments, resume handles, and recent visible turns', () => {
|
|
67
|
+
const pack = buildSessionContextPack({
|
|
68
|
+
session: session({
|
|
69
|
+
codexThreadId: 'codex-thread-1',
|
|
70
|
+
delegateResumeIds: { codex: 'delegate-codex-2' },
|
|
71
|
+
}),
|
|
72
|
+
messages: [
|
|
73
|
+
message({ text: 'Visible user ask', attachedFiles: ['/tmp/spec.md'] }),
|
|
74
|
+
message({ role: 'assistant', text: 'Hidden note', historyExcluded: true }),
|
|
75
|
+
message({ role: 'assistant', text: 'Release plan ready.', toolEvents: [{ name: 'shell', input: 'npm test', output: 'passed' }] }),
|
|
76
|
+
],
|
|
77
|
+
tasks: { 'task-1': task({ blockedBy: ['task-2'] }) },
|
|
78
|
+
now: 500,
|
|
79
|
+
maxRecentMessages: 8,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
assert.equal(pack.schemaVersion, 1)
|
|
83
|
+
assert.equal(pack.session.id, 'session-1')
|
|
84
|
+
assert.equal(pack.status, 'attention')
|
|
85
|
+
assert.equal(pack.linkedTasks.length, 1)
|
|
86
|
+
assert.equal(pack.linkedTasks[0]?.id, 'task-1')
|
|
87
|
+
assert.equal(pack.attachments[0]?.path, '/tmp/spec.md')
|
|
88
|
+
assert.deepEqual(pack.resumeHandles.map((handle) => handle.kind), ['codex', 'codex-delegate'])
|
|
89
|
+
assert.equal(pack.recentMessages.length, 2)
|
|
90
|
+
assert.ok(pack.recentMessages.every((item) => !item.text.includes('Hidden note')))
|
|
91
|
+
assert.ok(pack.nextActions.some((action) => action.includes('blocked linked task')))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('renders markdown without provider reasoning, tool output dumps, or hidden transcript turns', () => {
|
|
95
|
+
const pack = buildSessionContextPack({
|
|
96
|
+
session: session(),
|
|
97
|
+
messages: [
|
|
98
|
+
message({ text: 'Need release context.' }),
|
|
99
|
+
message({
|
|
100
|
+
role: 'assistant',
|
|
101
|
+
text: 'Here is the plan.',
|
|
102
|
+
reasoningContent: 'private reasoning',
|
|
103
|
+
thinking: 'internal thought stream',
|
|
104
|
+
toolEvents: [{ name: 'shell', input: 'npm test', output: 'very long output that should not be rendered' }],
|
|
105
|
+
}),
|
|
106
|
+
],
|
|
107
|
+
tasks: { 'task-1': task({ status: 'completed', result: 'Release shipped.' }) },
|
|
108
|
+
now: 800,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const markdown = formatSessionContextPackMarkdown(pack)
|
|
112
|
+
|
|
113
|
+
assert.match(markdown, /# Session Context Pack: Release chat/)
|
|
114
|
+
assert.match(markdown, /Provider: claude-cli/)
|
|
115
|
+
assert.match(markdown, /Linked Tasks/)
|
|
116
|
+
assert.match(markdown, /Recent Turns/)
|
|
117
|
+
assert.doesNotMatch(markdown, /private reasoning/)
|
|
118
|
+
assert.doesNotMatch(markdown, /internal thought stream/)
|
|
119
|
+
assert.doesNotMatch(markdown, /very long output/)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { getContextStatus, type ContextStatus } from '@/lib/server/context-manager'
|
|
2
|
+
import type { BoardTask, Message, Session } from '@/types'
|
|
3
|
+
|
|
4
|
+
const SYSTEM_PROMPT_TOKEN_ESTIMATE = 2000
|
|
5
|
+
const DEFAULT_RECENT_MESSAGES = 12
|
|
6
|
+
const MAX_RECENT_MESSAGES = 40
|
|
7
|
+
const MAX_TEXT_CHARS = 900
|
|
8
|
+
const MAX_SMALL_TEXT_CHARS = 220
|
|
9
|
+
const MAX_ATTACHMENTS = 20
|
|
10
|
+
|
|
11
|
+
export type SessionContextPackStatus = 'ready' | 'attention' | 'blocked'
|
|
12
|
+
|
|
13
|
+
export interface SessionContextPackResumeHandle {
|
|
14
|
+
kind: string
|
|
15
|
+
id: string
|
|
16
|
+
command: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SessionContextPackMessage {
|
|
20
|
+
role: Message['role']
|
|
21
|
+
time: number
|
|
22
|
+
kind: Message['kind'] | null
|
|
23
|
+
text: string
|
|
24
|
+
attachmentCount: number
|
|
25
|
+
toolCallNames: string[]
|
|
26
|
+
sourceLabel: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SessionContextPackTask {
|
|
30
|
+
id: string
|
|
31
|
+
title: string
|
|
32
|
+
status: BoardTask['status']
|
|
33
|
+
agentId: string | null
|
|
34
|
+
blockedBy: string[]
|
|
35
|
+
blocks: string[]
|
|
36
|
+
result: string | null
|
|
37
|
+
error: string | null
|
|
38
|
+
updatedAt: number | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SessionContextPackAttachment {
|
|
42
|
+
path: string
|
|
43
|
+
messageIndex: number
|
|
44
|
+
role: Message['role']
|
|
45
|
+
time: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SessionContextPack {
|
|
49
|
+
schemaVersion: 1
|
|
50
|
+
generatedAt: number
|
|
51
|
+
status: SessionContextPackStatus
|
|
52
|
+
session: {
|
|
53
|
+
id: string
|
|
54
|
+
name: string
|
|
55
|
+
agentId: string | null
|
|
56
|
+
provider: string
|
|
57
|
+
model: string
|
|
58
|
+
cwd: string
|
|
59
|
+
projectId: string | null
|
|
60
|
+
missionId: string | null
|
|
61
|
+
tools: string[]
|
|
62
|
+
extensions: string[]
|
|
63
|
+
}
|
|
64
|
+
connector: {
|
|
65
|
+
platform: string | null
|
|
66
|
+
connectorId: string | null
|
|
67
|
+
scope: string | null
|
|
68
|
+
threadId: string | null
|
|
69
|
+
senderName: string | null
|
|
70
|
+
}
|
|
71
|
+
messageStats: {
|
|
72
|
+
total: number
|
|
73
|
+
visible: number
|
|
74
|
+
hidden: number
|
|
75
|
+
attachments: number
|
|
76
|
+
toolEvents: number
|
|
77
|
+
lastMessageAt: number | null
|
|
78
|
+
}
|
|
79
|
+
context: ContextStatus
|
|
80
|
+
resumeHandles: SessionContextPackResumeHandle[]
|
|
81
|
+
linkedTasks: SessionContextPackTask[]
|
|
82
|
+
attachments: SessionContextPackAttachment[]
|
|
83
|
+
runContext: {
|
|
84
|
+
objective: string | null
|
|
85
|
+
constraints: string[]
|
|
86
|
+
keyFacts: string[]
|
|
87
|
+
currentPlan: string[]
|
|
88
|
+
completedSteps: string[]
|
|
89
|
+
blockers: string[]
|
|
90
|
+
updatedAt: number | null
|
|
91
|
+
}
|
|
92
|
+
recentMessages: SessionContextPackMessage[]
|
|
93
|
+
nextActions: string[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function compactText(value: unknown, maxChars = MAX_TEXT_CHARS): string {
|
|
97
|
+
if (typeof value !== 'string') return ''
|
|
98
|
+
const text = value.split(/\s+/).filter(Boolean).join(' ').trim()
|
|
99
|
+
if (!text) return ''
|
|
100
|
+
return text.length > maxChars ? `${text.slice(0, maxChars - 3)}...` : text
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function compactList(values: unknown, maxItems = 8): string[] {
|
|
104
|
+
if (!Array.isArray(values)) return []
|
|
105
|
+
return values
|
|
106
|
+
.map((item) => compactText(item, MAX_SMALL_TEXT_CHARS))
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.slice(0, maxItems)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sourceLabel(message: Message): string | null {
|
|
112
|
+
const source = message.source
|
|
113
|
+
if (!source) return null
|
|
114
|
+
return source.connectorName || source.platform || source.connectorId || null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isVisibleContextMessage(message: Message): boolean {
|
|
118
|
+
if (message.suppressed || message.historyExcluded) return false
|
|
119
|
+
if (message.kind === 'heartbeat' || message.kind === 'context-clear') return false
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function messageAttachmentPaths(message: Message): string[] {
|
|
124
|
+
const paths: string[] = []
|
|
125
|
+
if (Array.isArray(message.attachedFiles)) {
|
|
126
|
+
paths.push(...message.attachedFiles.filter((item): item is string => typeof item === 'string' && item.trim().length > 0))
|
|
127
|
+
}
|
|
128
|
+
if (typeof message.imagePath === 'string' && message.imagePath.trim()) paths.push(message.imagePath.trim())
|
|
129
|
+
if (typeof message.imageUrl === 'string' && message.imageUrl.trim()) paths.push(message.imageUrl.trim())
|
|
130
|
+
return Array.from(new Set(paths))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildRecentMessages(messages: Message[], maxRecentMessages: number): SessionContextPackMessage[] {
|
|
134
|
+
return messages
|
|
135
|
+
.filter(isVisibleContextMessage)
|
|
136
|
+
.slice(-maxRecentMessages)
|
|
137
|
+
.map((message) => ({
|
|
138
|
+
role: message.role,
|
|
139
|
+
time: message.time,
|
|
140
|
+
kind: message.kind || null,
|
|
141
|
+
text: compactText(message.text),
|
|
142
|
+
attachmentCount: messageAttachmentPaths(message).length,
|
|
143
|
+
toolCallNames: (message.toolEvents || [])
|
|
144
|
+
.map((event) => compactText(event.name, 80))
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.slice(0, 8),
|
|
147
|
+
sourceLabel: sourceLabel(message),
|
|
148
|
+
}))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildAttachments(messages: Message[]): SessionContextPackAttachment[] {
|
|
152
|
+
const attachments: SessionContextPackAttachment[] = []
|
|
153
|
+
messages.forEach((message, messageIndex) => {
|
|
154
|
+
for (const path of messageAttachmentPaths(message)) {
|
|
155
|
+
attachments.push({ path, messageIndex, role: message.role, time: message.time })
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
return attachments.slice(-MAX_ATTACHMENTS)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resumeHandles(session: Session): SessionContextPackResumeHandle[] {
|
|
162
|
+
const handles: SessionContextPackResumeHandle[] = []
|
|
163
|
+
const push = (kind: string, id: unknown, command: (value: string) => string) => {
|
|
164
|
+
if (typeof id !== 'string' || !id.trim()) return
|
|
165
|
+
const value = id.trim()
|
|
166
|
+
handles.push({ kind, id: value, command: command(value) })
|
|
167
|
+
}
|
|
168
|
+
push('claude', session.claudeSessionId, (id) => `claude --resume ${id}`)
|
|
169
|
+
push('codex', session.codexThreadId, (id) => `codex exec resume ${id}`)
|
|
170
|
+
push('opencode', session.opencodeSessionId, (id) => `opencode run "<task>" --session ${id}`)
|
|
171
|
+
push('gemini', session.geminiSessionId, (id) => `gemini --resume ${id} --prompt "<task>"`)
|
|
172
|
+
push('copilot', session.copilotSessionId, (id) => `copilot -p "<task>" --resume ${id}`)
|
|
173
|
+
push('droid', session.droidSessionId, (id) => `droid exec "<task>" --resume ${id}`)
|
|
174
|
+
push('cursor', session.cursorSessionId, (id) => `cursor-agent --resume ${id} --print "<task>"`)
|
|
175
|
+
push('qwen', session.qwenSessionId, (id) => `qwen --resume ${id} -p "<task>"`)
|
|
176
|
+
|
|
177
|
+
const delegate = session.delegateResumeIds || {}
|
|
178
|
+
push('claude-delegate', delegate.claudeCode, (id) => `claude --resume ${id}`)
|
|
179
|
+
push('codex-delegate', delegate.codex, (id) => `codex exec resume ${id}`)
|
|
180
|
+
push('opencode-delegate', delegate.opencode, (id) => `opencode run "<task>" --session ${id}`)
|
|
181
|
+
push('gemini-delegate', delegate.gemini, (id) => `gemini --resume ${id} --prompt "<task>"`)
|
|
182
|
+
push('copilot-delegate', delegate.copilot, (id) => `copilot -p "<task>" --resume ${id}`)
|
|
183
|
+
push('droid-delegate', delegate.droid, (id) => `droid exec "<task>" --resume ${id}`)
|
|
184
|
+
push('cursor-delegate', delegate.cursor, (id) => `cursor-agent --resume ${id} --print "<task>"`)
|
|
185
|
+
push('qwen-delegate', delegate.qwen, (id) => `qwen --resume ${id} -p "<task>"`)
|
|
186
|
+
return handles
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function linkedTasksForSession(session: Session, tasks: Record<string, BoardTask>): SessionContextPackTask[] {
|
|
190
|
+
return Object.values(tasks)
|
|
191
|
+
.filter((task) => task.sessionId === session.id || task.createdInSessionId === session.id)
|
|
192
|
+
.sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0))
|
|
193
|
+
.slice(0, 8)
|
|
194
|
+
.map((task) => ({
|
|
195
|
+
id: task.id,
|
|
196
|
+
title: task.title || task.id,
|
|
197
|
+
status: task.status,
|
|
198
|
+
agentId: task.agentId || null,
|
|
199
|
+
blockedBy: Array.isArray(task.blockedBy) ? task.blockedBy.filter(Boolean) : [],
|
|
200
|
+
blocks: Array.isArray(task.blocks) ? task.blocks.filter(Boolean) : [],
|
|
201
|
+
result: compactText(task.result, MAX_SMALL_TEXT_CHARS) || null,
|
|
202
|
+
error: compactText(task.error, MAX_SMALL_TEXT_CHARS) || null,
|
|
203
|
+
updatedAt: typeof task.updatedAt === 'number' ? task.updatedAt : null,
|
|
204
|
+
}))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildNextActions(input: {
|
|
208
|
+
context: ContextStatus
|
|
209
|
+
linkedTasks: SessionContextPackTask[]
|
|
210
|
+
recentMessages: SessionContextPackMessage[]
|
|
211
|
+
resumeHandles: SessionContextPackResumeHandle[]
|
|
212
|
+
session: Session
|
|
213
|
+
}): string[] {
|
|
214
|
+
const actions: string[] = []
|
|
215
|
+
if (input.context.strategy === 'critical') {
|
|
216
|
+
actions.push('Compact the chat or start a new context window before continuing long work.')
|
|
217
|
+
} else if (input.context.strategy === 'warning') {
|
|
218
|
+
actions.push('Consider compacting soon; the context window is approaching the reserve limit.')
|
|
219
|
+
}
|
|
220
|
+
if (input.linkedTasks.some((task) => task.blockedBy.length > 0 && task.status !== 'completed')) {
|
|
221
|
+
actions.push('Resolve the blocked linked task before handing this session to another operator.')
|
|
222
|
+
}
|
|
223
|
+
if (input.linkedTasks.some((task) => task.status === 'failed' || task.status === 'cancelled')) {
|
|
224
|
+
actions.push('Review failed or cancelled linked tasks before resuming the session.')
|
|
225
|
+
}
|
|
226
|
+
if (input.resumeHandles.length > 0) {
|
|
227
|
+
actions.push('Use a resume handle if continuing work in the matching CLI backend.')
|
|
228
|
+
}
|
|
229
|
+
if (!input.session.agentId) {
|
|
230
|
+
actions.push('Link an agent when this context needs durable memory, tools, or scheduled follow-up.')
|
|
231
|
+
}
|
|
232
|
+
if (input.recentMessages.length === 0) {
|
|
233
|
+
actions.push('Add the current objective before handing off; no visible recent turns are available.')
|
|
234
|
+
}
|
|
235
|
+
if (actions.length === 0) actions.push('Share this context pack with the next operator or agent before switching execution paths.')
|
|
236
|
+
return Array.from(new Set(actions)).slice(0, 8)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function statusFrom(context: ContextStatus, linkedTasks: SessionContextPackTask[], recentMessages: SessionContextPackMessage[]): SessionContextPackStatus {
|
|
240
|
+
if (context.strategy === 'critical') return 'blocked'
|
|
241
|
+
if (linkedTasks.some((task) => task.status === 'failed' || task.status === 'cancelled')) return 'blocked'
|
|
242
|
+
if (context.strategy === 'warning') return 'attention'
|
|
243
|
+
if (linkedTasks.some((task) => task.blockedBy.length > 0 && task.status !== 'completed')) return 'attention'
|
|
244
|
+
if (recentMessages.length === 0) return 'attention'
|
|
245
|
+
return 'ready'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function buildSessionContextPack(input: {
|
|
249
|
+
session: Session
|
|
250
|
+
messages: Message[]
|
|
251
|
+
tasks?: Record<string, BoardTask>
|
|
252
|
+
now?: number
|
|
253
|
+
maxRecentMessages?: number
|
|
254
|
+
}): SessionContextPack {
|
|
255
|
+
const now = input.now ?? Date.now()
|
|
256
|
+
const maxRecentMessages = Math.max(1, Math.min(MAX_RECENT_MESSAGES, Math.trunc(input.maxRecentMessages || DEFAULT_RECENT_MESSAGES)))
|
|
257
|
+
const messages = Array.isArray(input.messages) ? input.messages : []
|
|
258
|
+
const context = getContextStatus(messages, SYSTEM_PROMPT_TOKEN_ESTIMATE, String(input.session.provider || ''), String(input.session.model || ''))
|
|
259
|
+
const recentMessages = buildRecentMessages(messages, maxRecentMessages)
|
|
260
|
+
const attachments = buildAttachments(messages)
|
|
261
|
+
const linkedTasks = linkedTasksForSession(input.session, input.tasks || {})
|
|
262
|
+
const handles = resumeHandles(input.session)
|
|
263
|
+
const hidden = messages.filter((message) => !isVisibleContextMessage(message)).length
|
|
264
|
+
const toolEvents = messages.reduce((sum, message) => sum + (message.toolEvents?.length || 0), 0)
|
|
265
|
+
const nextActions = buildNextActions({ context, linkedTasks, recentMessages, resumeHandles: handles, session: input.session })
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
schemaVersion: 1,
|
|
269
|
+
generatedAt: now,
|
|
270
|
+
status: statusFrom(context, linkedTasks, recentMessages),
|
|
271
|
+
session: {
|
|
272
|
+
id: input.session.id,
|
|
273
|
+
name: input.session.name || input.session.id,
|
|
274
|
+
agentId: input.session.agentId || null,
|
|
275
|
+
provider: String(input.session.provider || ''),
|
|
276
|
+
model: String(input.session.model || ''),
|
|
277
|
+
cwd: input.session.cwd || '',
|
|
278
|
+
projectId: input.session.projectId || null,
|
|
279
|
+
missionId: input.session.missionId || null,
|
|
280
|
+
tools: Array.isArray(input.session.tools) ? input.session.tools.filter(Boolean) : [],
|
|
281
|
+
extensions: Array.isArray(input.session.extensions) ? input.session.extensions.filter(Boolean) : [],
|
|
282
|
+
},
|
|
283
|
+
connector: {
|
|
284
|
+
platform: input.session.connectorContext?.platform || null,
|
|
285
|
+
connectorId: input.session.connectorContext?.connectorId || null,
|
|
286
|
+
scope: input.session.connectorContext?.scope || null,
|
|
287
|
+
threadId: input.session.connectorContext?.threadId || null,
|
|
288
|
+
senderName: input.session.connectorContext?.senderName || null,
|
|
289
|
+
},
|
|
290
|
+
messageStats: {
|
|
291
|
+
total: messages.length,
|
|
292
|
+
visible: messages.length - hidden,
|
|
293
|
+
hidden,
|
|
294
|
+
attachments: attachments.length,
|
|
295
|
+
toolEvents,
|
|
296
|
+
lastMessageAt: messages.at(-1)?.time || null,
|
|
297
|
+
},
|
|
298
|
+
context,
|
|
299
|
+
resumeHandles: handles,
|
|
300
|
+
linkedTasks,
|
|
301
|
+
attachments,
|
|
302
|
+
runContext: {
|
|
303
|
+
objective: compactText(input.session.runContext?.objective, MAX_SMALL_TEXT_CHARS) || null,
|
|
304
|
+
constraints: compactList(input.session.runContext?.constraints),
|
|
305
|
+
keyFacts: compactList(input.session.runContext?.keyFacts),
|
|
306
|
+
currentPlan: compactList(input.session.runContext?.currentPlan),
|
|
307
|
+
completedSteps: compactList(input.session.runContext?.completedSteps),
|
|
308
|
+
blockers: compactList(input.session.runContext?.blockers),
|
|
309
|
+
updatedAt: input.session.runContext?.updatedAt || null,
|
|
310
|
+
},
|
|
311
|
+
recentMessages,
|
|
312
|
+
nextActions,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function iso(value: number | null | undefined): string {
|
|
317
|
+
return typeof value === 'number' && Number.isFinite(value) ? new Date(value).toISOString() : 'n/a'
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function bulletList(items: string[], empty: string): string[] {
|
|
321
|
+
if (items.length === 0) return [`- ${empty}`]
|
|
322
|
+
return items.map((item) => `- ${item}`)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function formatSessionContextPackMarkdown(pack: SessionContextPack): string {
|
|
326
|
+
const lines: string[] = []
|
|
327
|
+
lines.push(`# Session Context Pack: ${pack.session.name}`)
|
|
328
|
+
lines.push('')
|
|
329
|
+
lines.push(`Generated: ${iso(pack.generatedAt)}`)
|
|
330
|
+
lines.push(`Status: ${pack.status}`)
|
|
331
|
+
lines.push(`Session: ${pack.session.id}`)
|
|
332
|
+
lines.push(`Provider: ${pack.session.provider}${pack.session.model ? ` / ${pack.session.model}` : ''}`)
|
|
333
|
+
lines.push(`Agent: ${pack.session.agentId || 'n/a'}`)
|
|
334
|
+
lines.push(`Working directory: ${pack.session.cwd || 'n/a'}`)
|
|
335
|
+
lines.push(`Messages: ${pack.messageStats.visible} visible / ${pack.messageStats.total} total`)
|
|
336
|
+
lines.push(`Context: ${pack.context.percentUsed}% used, ${pack.context.remainingTokens.toLocaleString()} tokens remaining`)
|
|
337
|
+
lines.push('')
|
|
338
|
+
lines.push('## Next Actions')
|
|
339
|
+
lines.push(...bulletList(pack.nextActions, 'No immediate action required.'))
|
|
340
|
+
lines.push('')
|
|
341
|
+
lines.push('## Run Context')
|
|
342
|
+
lines.push(`Objective: ${pack.runContext.objective || 'n/a'}`)
|
|
343
|
+
lines.push(...bulletList(pack.runContext.currentPlan.map((item) => `Plan: ${item}`), 'No current plan recorded.'))
|
|
344
|
+
lines.push(...bulletList(pack.runContext.blockers.map((item) => `Blocker: ${item}`), 'No blockers recorded.'))
|
|
345
|
+
lines.push('')
|
|
346
|
+
lines.push('## Linked Tasks')
|
|
347
|
+
if (pack.linkedTasks.length === 0) {
|
|
348
|
+
lines.push('- No linked tasks.')
|
|
349
|
+
} else {
|
|
350
|
+
for (const task of pack.linkedTasks) {
|
|
351
|
+
const blockers = task.blockedBy.length > 0 ? `, blocked by ${task.blockedBy.join(', ')}` : ''
|
|
352
|
+
lines.push(`- ${task.id}: ${task.title} (${task.status}${blockers})`)
|
|
353
|
+
if (task.result) lines.push(` - Result: ${task.result}`)
|
|
354
|
+
if (task.error) lines.push(` - Error: ${task.error}`)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
lines.push('')
|
|
358
|
+
lines.push('## Resume Handles')
|
|
359
|
+
if (pack.resumeHandles.length === 0) {
|
|
360
|
+
lines.push('- No external resume handles.')
|
|
361
|
+
} else {
|
|
362
|
+
for (const handle of pack.resumeHandles) lines.push(`- ${handle.kind}: \`${handle.command}\``)
|
|
363
|
+
}
|
|
364
|
+
lines.push('')
|
|
365
|
+
lines.push('## Attachments')
|
|
366
|
+
if (pack.attachments.length === 0) {
|
|
367
|
+
lines.push('- No attachments in the visible pack window.')
|
|
368
|
+
} else {
|
|
369
|
+
for (const attachment of pack.attachments) {
|
|
370
|
+
lines.push(`- ${attachment.path} (${attachment.role}, ${iso(attachment.time)})`)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
lines.push('')
|
|
374
|
+
lines.push('## Recent Turns')
|
|
375
|
+
if (pack.recentMessages.length === 0) {
|
|
376
|
+
lines.push('- No visible recent turns.')
|
|
377
|
+
} else {
|
|
378
|
+
for (const message of pack.recentMessages) {
|
|
379
|
+
const tools = message.toolCallNames.length > 0 ? ` Tools: ${message.toolCallNames.join(', ')}.` : ''
|
|
380
|
+
const attachments = message.attachmentCount > 0 ? ` Attachments: ${message.attachmentCount}.` : ''
|
|
381
|
+
const source = message.sourceLabel ? ` Source: ${message.sourceLabel}.` : ''
|
|
382
|
+
lines.push(`- ${message.role} at ${iso(message.time)}:${source}${attachments}${tools} ${message.text || '[no text]'}`.trim())
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
lines.push('')
|
|
386
|
+
return lines.join('\n')
|
|
387
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generates concise abstracts (~100 tokens) for memory entries.
|
|
3
|
-
* Inspired by OpenViking's L0/L1/L2 tiered context representations.
|
|
4
3
|
*
|
|
5
4
|
* Used in proactive recall to inject summaries instead of truncated raw content,
|
|
6
5
|
* reducing token waste and preserving semantic meaning.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildRunHandoffPacket,
|
|
6
|
+
formatRunHandoffMarkdown,
|
|
7
|
+
} from './run-handoff'
|
|
8
|
+
import type { EvidenceArtifact, RunBrief, SessionRunRecord } from '@/types'
|
|
9
|
+
|
|
10
|
+
function run(overrides: Partial<SessionRunRecord> = {}): SessionRunRecord {
|
|
11
|
+
return {
|
|
12
|
+
id: overrides.id || 'run_1',
|
|
13
|
+
sessionId: overrides.sessionId || 'sess_1',
|
|
14
|
+
source: overrides.source || 'task',
|
|
15
|
+
internal: overrides.internal ?? false,
|
|
16
|
+
mode: overrides.mode || 'direct',
|
|
17
|
+
status: overrides.status || 'completed',
|
|
18
|
+
messagePreview: overrides.messagePreview || 'Verify the release',
|
|
19
|
+
queuedAt: overrides.queuedAt ?? 1000,
|
|
20
|
+
startedAt: overrides.startedAt ?? 1500,
|
|
21
|
+
endedAt: overrides.endedAt ?? 4500,
|
|
22
|
+
resultPreview: overrides.resultPreview || 'Release verified with browser smoke evidence.',
|
|
23
|
+
ownerType: overrides.ownerType ?? 'task',
|
|
24
|
+
ownerId: overrides.ownerId ?? 'task_1',
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function brief(overrides: Partial<RunBrief> = {}): RunBrief {
|
|
30
|
+
return {
|
|
31
|
+
runId: overrides.runId || 'run_1',
|
|
32
|
+
sessionId: overrides.sessionId || 'sess_1',
|
|
33
|
+
title: overrides.title || 'Verify the release',
|
|
34
|
+
objective: overrides.objective || 'Verify the release',
|
|
35
|
+
status: overrides.status || 'completed',
|
|
36
|
+
source: overrides.source || 'task',
|
|
37
|
+
owner: overrides.owner ?? { type: 'task', id: 'task_1' },
|
|
38
|
+
timeline: overrides.timeline || [
|
|
39
|
+
{ label: 'Queued', status: 'queued', at: 1000 },
|
|
40
|
+
{ label: 'Started', status: 'running', at: 1500 },
|
|
41
|
+
{ label: 'Ended', status: 'completed', at: 4500 },
|
|
42
|
+
],
|
|
43
|
+
result: overrides.result ?? 'Release verified with browser smoke evidence.',
|
|
44
|
+
error: overrides.error ?? null,
|
|
45
|
+
warnings: overrides.warnings || [],
|
|
46
|
+
usage: overrides.usage || {
|
|
47
|
+
inputTokens: 10,
|
|
48
|
+
outputTokens: 20,
|
|
49
|
+
estimatedCost: 0.01,
|
|
50
|
+
citationCount: 1,
|
|
51
|
+
sourceIds: ['source_1'],
|
|
52
|
+
},
|
|
53
|
+
evidence: overrides.evidence || [{
|
|
54
|
+
id: 'evidence_1',
|
|
55
|
+
kind: 'event',
|
|
56
|
+
title: 'Smoke test',
|
|
57
|
+
summary: 'Browser smoke passed.',
|
|
58
|
+
createdAt: 4300,
|
|
59
|
+
}],
|
|
60
|
+
generatedAt: overrides.generatedAt ?? 5000,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function artifact(overrides: Partial<EvidenceArtifact> = {}): EvidenceArtifact {
|
|
65
|
+
return {
|
|
66
|
+
id: overrides.id || 'artifact_1',
|
|
67
|
+
kind: overrides.kind || 'run_result',
|
|
68
|
+
title: overrides.title || 'Run result',
|
|
69
|
+
preview: overrides.preview || 'Release verified.',
|
|
70
|
+
createdAt: overrides.createdAt ?? 4500,
|
|
71
|
+
source: overrides.source || { type: 'run', id: 'run_1', label: 'Verify the release' },
|
|
72
|
+
...overrides,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('run handoff packets', () => {
|
|
77
|
+
it('summarizes a completed run with evidence, artifacts, and resume commands', () => {
|
|
78
|
+
const packet = buildRunHandoffPacket(run(), brief(), [artifact()], 6000)
|
|
79
|
+
|
|
80
|
+
assert.equal(packet.schemaVersion, 1)
|
|
81
|
+
assert.equal(packet.runId, 'run_1')
|
|
82
|
+
assert.equal(packet.readiness.status, 'ready')
|
|
83
|
+
assert.equal(packet.timing.durationMs, 3000)
|
|
84
|
+
assert.equal(packet.evidence.length, 1)
|
|
85
|
+
assert.equal(packet.artifacts.length, 1)
|
|
86
|
+
assert.ok(packet.resume.commands.some((command) => command.includes('swarmclaw runs handoff run_1')))
|
|
87
|
+
assert.deepEqual(packet.readiness.recommendedActions, ['Handoff packet is ready to share.'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('marks failed and under-evidenced runs as needing attention', () => {
|
|
91
|
+
const packet = buildRunHandoffPacket(
|
|
92
|
+
run({ status: 'failed', error: 'Provider timed out.', resultPreview: undefined }),
|
|
93
|
+
brief({ status: 'failed', result: null, error: 'Provider timed out.', warnings: ['Run failed and needs review before using the result.'], evidence: [] }),
|
|
94
|
+
[],
|
|
95
|
+
6000,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert.equal(packet.readiness.status, 'blocked')
|
|
99
|
+
assert.ok(packet.readiness.recommendedActions.some((action) => action.includes('Review the run error')))
|
|
100
|
+
assert.ok(packet.outcome.warnings.length > 0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('formats concise markdown for handoff into another operator context', () => {
|
|
104
|
+
const markdown = formatRunHandoffMarkdown(buildRunHandoffPacket(run(), brief(), [artifact({ url: '/api/files/serve?path=result.md' })], 6000))
|
|
105
|
+
|
|
106
|
+
assert.match(markdown, /^# Run Handoff: Verify the release/)
|
|
107
|
+
assert.match(markdown, /Run ID: run_1/)
|
|
108
|
+
assert.match(markdown, /## Outcome/)
|
|
109
|
+
assert.match(markdown, /Browser smoke passed/)
|
|
110
|
+
assert.match(markdown, /swarmclaw chats context-pack sess_1/)
|
|
111
|
+
})
|
|
112
|
+
})
|