@swarmclawai/swarmclaw 1.9.13 → 1.9.14
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 +9 -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/cli/index.js +1 -0
- package/src/cli/spec.js +1 -0
- package/src/components/chat/chat-header.tsx +36 -3
- 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/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/README.md
CHANGED
|
@@ -399,6 +399,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.9.14 Highlights
|
|
403
|
+
|
|
404
|
+
Session context-pack release: SwarmClaw now turns a live chat into a concise handoff packet with session metadata, recent visible turns, linked tasks, attachments, resume handles, and next actions.
|
|
405
|
+
|
|
406
|
+
- **Context-pack API.** `GET /api/chats/:id/context-pack` returns structured handoff JSON, and `?format=markdown` returns copyable markdown.
|
|
407
|
+
- **Chat header copy action.** Active chats with messages expose a context-pack button for quick handoff to another operator or backend.
|
|
408
|
+
- **CLI access.** `swarmclaw chats context-pack <chatId> --query format=markdown` exposes the same packet for scripts and release automation.
|
|
409
|
+
- **Smoke coverage.** Runtime tests and the browser smoke gate now verify the context-pack route and markdown response.
|
|
410
|
+
|
|
402
411
|
### v1.9.13 Highlights
|
|
403
412
|
|
|
404
413
|
Architecture health release: SwarmClaw now turns runtime ownership, dispatch, memory, startup, and quality evidence into a scored operator report.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.14",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
|
|
89
89
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
|
|
90
90
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
91
|
-
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
|
|
91
|
+
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
|
|
92
92
|
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
|
|
93
93
|
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
|
|
94
94
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import { buildSessionContextPack, formatSessionContextPackMarkdown } from '@/lib/server/chats/session-context-pack'
|
|
4
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
5
|
+
import { getMessages } from '@/lib/server/messages/message-repository'
|
|
6
|
+
import { getSession } from '@/lib/server/sessions/session-repository'
|
|
7
|
+
import { listTasks } from '@/lib/server/tasks/task-repository'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RECENT_MESSAGES = 12
|
|
10
|
+
const MAX_RECENT_MESSAGES = 40
|
|
11
|
+
|
|
12
|
+
function parseMaxRecentMessages(req: Request): number {
|
|
13
|
+
const url = new URL(req.url)
|
|
14
|
+
const raw = url.searchParams.get('messages') || url.searchParams.get('maxRecentMessages')
|
|
15
|
+
const parsed = Number.parseInt(raw || '', 10)
|
|
16
|
+
if (!Number.isFinite(parsed)) return DEFAULT_RECENT_MESSAGES
|
|
17
|
+
return Math.max(1, Math.min(MAX_RECENT_MESSAGES, parsed))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
21
|
+
const { id } = await params
|
|
22
|
+
const session = getSession(id)
|
|
23
|
+
if (!session) return notFound()
|
|
24
|
+
|
|
25
|
+
const url = new URL(req.url)
|
|
26
|
+
const format = url.searchParams.get('format')
|
|
27
|
+
const pack = buildSessionContextPack({
|
|
28
|
+
session,
|
|
29
|
+
messages: getMessages(id),
|
|
30
|
+
tasks: listTasks(),
|
|
31
|
+
maxRecentMessages: parseMaxRecentMessages(req),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (format === 'markdown') {
|
|
35
|
+
return new Response(formatSessionContextPackMarkdown(pack), {
|
|
36
|
+
headers: {
|
|
37
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return NextResponse.json(pack)
|
|
43
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('GET /api/chats/[id]/context-pack returns structured and markdown handoff context', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
markdownStatus: number
|
|
10
|
+
missingStatus: number
|
|
11
|
+
schemaVersion: number
|
|
12
|
+
linkedTaskCount: number
|
|
13
|
+
recentMessageCount: number
|
|
14
|
+
markdownContentType: string
|
|
15
|
+
markdownIncludesTitle: boolean
|
|
16
|
+
markdownIncludesTask: boolean
|
|
17
|
+
}>(`
|
|
18
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
19
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
20
|
+
const routeMod = await import('./src/app/api/chats/[id]/context-pack/route')
|
|
21
|
+
const storage = storageMod.default || storageMod
|
|
22
|
+
const repo = repoMod.default || repoMod
|
|
23
|
+
const route = routeMod.default || routeMod
|
|
24
|
+
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
storage.saveSessions({
|
|
27
|
+
sess_pack_1: {
|
|
28
|
+
id: 'sess_pack_1',
|
|
29
|
+
name: 'Context pack test',
|
|
30
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
31
|
+
user: 'tester',
|
|
32
|
+
provider: 'openai',
|
|
33
|
+
model: 'gpt-4o-mini',
|
|
34
|
+
claudeSessionId: null,
|
|
35
|
+
codexThreadId: 'codex-pack-thread',
|
|
36
|
+
messages: [],
|
|
37
|
+
createdAt: now,
|
|
38
|
+
lastActiveAt: now,
|
|
39
|
+
runContext: {
|
|
40
|
+
objective: 'Hand off a release session.',
|
|
41
|
+
constraints: ['Keep the pack short.'],
|
|
42
|
+
keyFacts: ['The current tag is local only.'],
|
|
43
|
+
discoveries: [],
|
|
44
|
+
failedApproaches: [],
|
|
45
|
+
currentPlan: ['Run checks'],
|
|
46
|
+
completedSteps: [],
|
|
47
|
+
blockers: [],
|
|
48
|
+
parentContext: null,
|
|
49
|
+
updatedAt: now,
|
|
50
|
+
version: 1,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
storage.saveTasks({
|
|
55
|
+
task_pack_1: {
|
|
56
|
+
id: 'task_pack_1',
|
|
57
|
+
title: 'Verify context pack',
|
|
58
|
+
description: 'Exercise API route.',
|
|
59
|
+
status: 'running',
|
|
60
|
+
sessionId: 'sess_pack_1',
|
|
61
|
+
agentId: null,
|
|
62
|
+
createdAt: now,
|
|
63
|
+
updatedAt: now + 3,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
repo.appendMessage('sess_pack_1', { role: 'user', text: 'Prepare handoff.', time: now, attachedFiles: ['/tmp/handoff.md'] })
|
|
68
|
+
repo.appendMessage('sess_pack_1', { role: 'assistant', text: 'Ready.', time: now + 1 })
|
|
69
|
+
|
|
70
|
+
const response = await route.GET(
|
|
71
|
+
new Request('http://local/api/chats/sess_pack_1/context-pack?messages=1'),
|
|
72
|
+
{ params: Promise.resolve({ id: 'sess_pack_1' }) },
|
|
73
|
+
)
|
|
74
|
+
const payload = await response.json()
|
|
75
|
+
|
|
76
|
+
const markdownResponse = await route.GET(
|
|
77
|
+
new Request('http://local/api/chats/sess_pack_1/context-pack?format=markdown'),
|
|
78
|
+
{ params: Promise.resolve({ id: 'sess_pack_1' }) },
|
|
79
|
+
)
|
|
80
|
+
const markdown = await markdownResponse.text()
|
|
81
|
+
|
|
82
|
+
const missingResponse = await route.GET(
|
|
83
|
+
new Request('http://local/api/chats/missing/context-pack'),
|
|
84
|
+
{ params: Promise.resolve({ id: 'missing' }) },
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
console.log(JSON.stringify({
|
|
88
|
+
status: response.status,
|
|
89
|
+
markdownStatus: markdownResponse.status,
|
|
90
|
+
missingStatus: missingResponse.status,
|
|
91
|
+
schemaVersion: payload.schemaVersion,
|
|
92
|
+
linkedTaskCount: payload.linkedTasks.length,
|
|
93
|
+
recentMessageCount: payload.recentMessages.length,
|
|
94
|
+
markdownContentType: markdownResponse.headers.get('content-type') || '',
|
|
95
|
+
markdownIncludesTitle: markdown.includes('# Session Context Pack: Context pack test'),
|
|
96
|
+
markdownIncludesTask: markdown.includes('Verify context pack'),
|
|
97
|
+
}))
|
|
98
|
+
`, { prefix: 'swarmclaw-context-pack-route-' })
|
|
99
|
+
|
|
100
|
+
assert.equal(output.status, 200)
|
|
101
|
+
assert.equal(output.markdownStatus, 200)
|
|
102
|
+
assert.equal(output.missingStatus, 404)
|
|
103
|
+
assert.equal(output.schemaVersion, 1)
|
|
104
|
+
assert.equal(output.linkedTaskCount, 1)
|
|
105
|
+
assert.equal(output.recentMessageCount, 1)
|
|
106
|
+
assert.match(output.markdownContentType, /text\/markdown/)
|
|
107
|
+
assert.equal(output.markdownIncludesTitle, true)
|
|
108
|
+
assert.equal(output.markdownIncludesTask, true)
|
|
109
|
+
})
|
package/src/cli/index.js
CHANGED
|
@@ -627,6 +627,7 @@ const COMMAND_GROUPS = [
|
|
|
627
627
|
cmd('clear-undo', 'POST', '/chats/:id/clear/undo', 'Restore a cleared chat via its undoToken', { expectsJsonBody: true }),
|
|
628
628
|
cmd('compact', 'POST', '/chats/:id/compact', 'Summarize and compact chat history (accepts optional keepLastN)', { expectsJsonBody: true }),
|
|
629
629
|
cmd('context-status', 'GET', '/chats/:id/context-status', 'Report token usage and context-window status for a chat'),
|
|
630
|
+
cmd('context-pack', 'GET', '/chats/:id/context-pack', 'Get a copyable session context pack for handoff'),
|
|
630
631
|
cmd('browser-status', 'GET', '/chats/:id/browser', 'Check browser status'),
|
|
631
632
|
cmd('browser-close', 'DELETE', '/chats/:id/browser', 'Close browser'),
|
|
632
633
|
cmd('mailbox', 'GET', '/chats/:id/mailbox', 'List chat mailbox envelopes'),
|
package/src/cli/spec.js
CHANGED
|
@@ -458,6 +458,7 @@ const COMMAND_GROUPS = {
|
|
|
458
458
|
'clear-undo': { description: 'Restore a cleared chat via its undoToken', method: 'POST', path: '/chats/:id/clear/undo', params: ['id'] },
|
|
459
459
|
compact: { description: 'Summarize and compact chat history', method: 'POST', path: '/chats/:id/compact', params: ['id'] },
|
|
460
460
|
'context-status': { description: 'Report token usage and context-window status', method: 'GET', path: '/chats/:id/context-status', params: ['id'] },
|
|
461
|
+
'context-pack': { description: 'Get a copyable session context pack for handoff', method: 'GET', path: '/chats/:id/context-pack', params: ['id'] },
|
|
461
462
|
mailbox: { description: 'List mailbox envelopes for a chat', method: 'GET', path: '/chats/:id/mailbox', params: ['id'] },
|
|
462
463
|
'mailbox-action': { description: 'Send/ack/clear mailbox envelopes', method: 'POST', path: '/chats/:id/mailbox', params: ['id'] },
|
|
463
464
|
queue: { description: 'List queued follow-up turns for a chat', method: 'GET', path: '/chats/:id/queue', params: ['id'] },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useMemo, useRef, type ReactNode } from 'react'
|
|
4
|
-
import { Plus } from 'lucide-react'
|
|
4
|
+
import { ClipboardList, Plus } from 'lucide-react'
|
|
5
5
|
import type { Session } from '@/types'
|
|
6
6
|
import { useAppStore } from '@/stores/use-app-store'
|
|
7
7
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
@@ -108,6 +108,8 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
108
108
|
const connectorMeta = connector ? resolveConnectorPlatformMeta(connector.platform) : null
|
|
109
109
|
const connectorPresence = connector?.presence
|
|
110
110
|
const [copied, setCopied] = useState(false)
|
|
111
|
+
const [contextPackCopied, setContextPackCopied] = useState(false)
|
|
112
|
+
const [contextPackLoading, setContextPackLoading] = useState(false)
|
|
111
113
|
const [sourceDropdownOpen, setSourceDropdownOpen] = useState(false)
|
|
112
114
|
const sourceDropdownRef = useRef<HTMLDivElement>(null)
|
|
113
115
|
const [renaming, setRenaming] = useState(false)
|
|
@@ -220,6 +222,22 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
220
222
|
})
|
|
221
223
|
}
|
|
222
224
|
|
|
225
|
+
const handleCopyContextPack = async () => {
|
|
226
|
+
if (contextPackLoading) return
|
|
227
|
+
setContextPackLoading(true)
|
|
228
|
+
try {
|
|
229
|
+
const markdown = await api<string>('GET', `/chats/${session.id}/context-pack?format=markdown`)
|
|
230
|
+
const copiedPack = await copyTextToClipboard(markdown)
|
|
231
|
+
if (!copiedPack) return
|
|
232
|
+
setContextPackCopied(true)
|
|
233
|
+
setTimeout(() => setContextPackCopied(false), 2000)
|
|
234
|
+
} catch {
|
|
235
|
+
// Best-effort clipboard helper; the route remains available from the CLI.
|
|
236
|
+
} finally {
|
|
237
|
+
setContextPackLoading(false)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
223
241
|
const handleDismissResumeHandle = async (e: React.MouseEvent) => {
|
|
224
242
|
e.stopPropagation()
|
|
225
243
|
try {
|
|
@@ -302,10 +320,11 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
302
320
|
}
|
|
303
321
|
}, [session.name, loadConnectors])
|
|
304
322
|
|
|
305
|
-
// Context bar shows for memories, source filter, task links, resume handles, browser
|
|
323
|
+
// Context bar shows for handoff packs, memories, source filter, task links, resume handles, browser
|
|
324
|
+
const hasContextPack = messageCount > 0
|
|
306
325
|
const hasMemoryLink = !!(agent && getEnabledToolIds(session).includes('memory'))
|
|
307
326
|
const hasSourceFilter = !!hasMultipleSources
|
|
308
|
-
const hasContextBar = !!(hasMemoryLink || hasSourceFilter || linkedTask || resumeHandle || browserActive)
|
|
327
|
+
const hasContextBar = !!(hasContextPack || hasMemoryLink || hasSourceFilter || linkedTask || resumeHandle || browserActive)
|
|
309
328
|
|
|
310
329
|
return (
|
|
311
330
|
<>
|
|
@@ -523,6 +542,20 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
523
542
|
{hasContextBar && (
|
|
524
543
|
<div className="border-t border-white/[0.05] bg-black/[0.08] px-4 py-2">
|
|
525
544
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
545
|
+
{hasContextPack && (
|
|
546
|
+
<Tip label="Copy session context pack">
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
onClick={handleCopyContextPack}
|
|
550
|
+
disabled={contextPackLoading}
|
|
551
|
+
aria-label="Copy session context pack"
|
|
552
|
+
className="flex min-w-[122px] items-center justify-center gap-1 px-2.5 py-1 rounded-[8px] bg-white/[0.03] hover:bg-white/[0.06] disabled:opacity-70 disabled:cursor-wait transition-colors cursor-pointer text-[10px] font-600 text-text-3/60 hover:text-text-2 shrink-0"
|
|
553
|
+
>
|
|
554
|
+
<ClipboardList className="h-3 w-3 shrink-0" aria-hidden="true" strokeWidth={2.2} />
|
|
555
|
+
<span>{contextPackLoading ? 'Packing' : contextPackCopied ? 'Copied' : 'Context pack'}</span>
|
|
556
|
+
</button>
|
|
557
|
+
</Tip>
|
|
558
|
+
)}
|
|
526
559
|
{hasMemoryLink && (
|
|
527
560
|
<Tip label="View agent memories">
|
|
528
561
|
<button
|
|
@@ -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.
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Replaces the implicit `source: 'heartbeat' | 'heartbeat-wake'` convention
|
|
5
5
|
* with a formal enum that determines routing, priority, and isolation behavior.
|
|
6
|
-
*
|
|
7
|
-
* Inspired by OpenClaw's separation of "run now" vs "queue next heartbeat" vs
|
|
8
|
-
* scheduled execution with proper isolation.
|
|
9
6
|
*/
|
|
10
7
|
|
|
11
8
|
// ── WakeMode enum ───────────────────────────────────────────────────────
|
|
@@ -84,8 +84,8 @@ export function budgetSkillsForPrompt(
|
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* Prescriptive skill adherence header.
|
|
87
|
-
* This tells the model exactly when and how to use skills
|
|
88
|
-
*
|
|
87
|
+
* This tells the model exactly when and how to use skills so it can keep
|
|
88
|
+
* skill-backed turns focused and economical.
|
|
89
89
|
*/
|
|
90
90
|
const SKILL_ADHERENCE_HEADER = `## Skills
|
|
91
91
|
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workspace context injection — injects workspace files into the agent's system prompt.
|
|
3
3
|
*
|
|
4
|
-
* Inspired by OpenClaw's pattern of injecting HEARTBEAT.md, IDENTITY.md, AGENTS.md,
|
|
5
|
-
* SOUL.md, TOOLS.md, USER.md, and BOOTSTRAP.md into every agent turn.
|
|
6
|
-
*
|
|
7
4
|
* This gives agents self-awareness, goals, and context about their operating environment
|
|
8
5
|
* without requiring the user to manually configure everything.
|
|
9
6
|
*/
|