diff --git a/apps/codex-claw/src/components/export-menu.tsx b/apps/codex-claw/src/components/export-menu.tsx index 4b18141..b77e027 100644 --- a/apps/codex-claw/src/components/export-menu.tsx +++ b/apps/codex-claw/src/components/export-menu.tsx @@ -4,6 +4,7 @@ import { useCallback, useState } from 'react' import { HugeiconsIcon } from '@hugeicons/react' import { Download01Icon } from '@hugeicons/core-free-icons' +import type { ExportFormat } from '@/hooks/use-export' import { MenuContent, MenuItem, @@ -19,8 +20,6 @@ import { import { buttonVariants } from '@/components/ui/button' import { cn } from '@/lib/utils' -type ExportFormat = 'markdown' | 'json' | 'text' - type ExportMenuProps = { onExport: (format: ExportFormat) => void disabled?: boolean @@ -30,6 +29,9 @@ const formats: Array<{ format: ExportFormat; label: string; ext: string }> = [ { format: 'markdown', label: 'Markdown', ext: '.md' }, { format: 'json', label: 'JSON', ext: '.json' }, { format: 'text', label: 'Plain Text', ext: '.txt' }, + { format: 'bundle', label: 'Redacted Bundle', ext: '.md' }, + { format: 'issue-draft', label: 'Issue Draft', ext: '.md' }, + { format: 'pr-draft', label: 'PR Draft', ext: '.md' }, ] export function ExportMenu({ onExport, disabled }: ExportMenuProps) { diff --git a/apps/codex-claw/src/hooks/use-export.ts b/apps/codex-claw/src/hooks/use-export.ts index a744eac..adfa046 100644 --- a/apps/codex-claw/src/hooks/use-export.ts +++ b/apps/codex-claw/src/hooks/use-export.ts @@ -5,7 +5,13 @@ import { chatQueryKeys } from '../screens/chat/chat-queries' import { getMessageTimestamp, textFromMessage } from '../screens/chat/utils' import type { GatewayMessage, HistoryResponse } from '../screens/chat/types' -type ExportFormat = 'markdown' | 'json' | 'text' +export type ExportFormat = + | 'markdown' + | 'json' + | 'text' + | 'bundle' + | 'issue-draft' + | 'pr-draft' type UseExportInput = { currentFriendlyId: string @@ -22,6 +28,15 @@ export function useExport({ const exportConversation = useCallback( function exportConversation(format: ExportFormat) { + if (isServerExport(format)) { + exportServerHandoff({ + currentFriendlyId, + currentSessionKey, + format, + }) + return + } + const historyKey = chatQueryKeys.history( currentFriendlyId, currentSessionKey || currentFriendlyId, @@ -69,6 +84,38 @@ export function useExport({ return { exportConversation } } +function isServerExport(format: ExportFormat) { + return ( + format === 'bundle' || + format === 'issue-draft' || + format === 'pr-draft' + ) +} + +function handoffKind(format: ExportFormat) { + if (format === 'issue-draft') return 'issue' + if (format === 'pr-draft') return 'pr' + return 'bundle' +} + +function exportServerHandoff(input: { + currentFriendlyId: string + currentSessionKey: string + format: ExportFormat +}) { + if (typeof window === 'undefined') return + const params = new URLSearchParams() + params.set('kind', handoffKind(input.format)) + params.set('download', '1') + if (input.currentSessionKey) { + params.set('sessionKey', input.currentSessionKey) + } + if (input.currentFriendlyId) { + params.set('friendlyId', input.currentFriendlyId) + } + window.location.assign('/api/session-bundle?' + params.toString()) +} + function formatTimestamp(message: GatewayMessage): string { const ts = getMessageTimestamp(message) return new Date(ts).toLocaleString('en-GB', { diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index 2894c88..75e1dc1 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ApiWorkspacesRouteImport } from './routes/api/workspaces' import { Route as ApiTasksRouteImport } from './routes/api/tasks' import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiSessionsRouteImport } from './routes/api/sessions' +import { Route as ApiSessionBundleRouteImport } from './routes/api/session-bundle' import { Route as ApiSendRouteImport } from './routes/api/send' import { Route as ApiRunEventsRouteImport } from './routes/api/run-events' import { Route as ApiRepoContextRouteImport } from './routes/api/repo-context' @@ -68,6 +69,11 @@ const ApiSessionsRoute = ApiSessionsRouteImport.update({ path: '/api/sessions', getParentRoute: () => rootRouteImport, } as any) +const ApiSessionBundleRoute = ApiSessionBundleRouteImport.update({ + id: '/api/session-bundle', + path: '/api/session-bundle', + getParentRoute: () => rootRouteImport, +} as any) const ApiSendRoute = ApiSendRouteImport.update({ id: '/api/send', path: '/api/send', @@ -133,6 +139,7 @@ export interface FileRoutesByFullPath { '/api/repo-context': typeof ApiRepoContextRoute '/api/run-events': typeof ApiRunEventsRoute '/api/send': typeof ApiSendRoute + '/api/session-bundle': typeof ApiSessionBundleRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute '/api/tasks': typeof ApiTasksRoute @@ -153,6 +160,7 @@ export interface FileRoutesByTo { '/api/repo-context': typeof ApiRepoContextRoute '/api/run-events': typeof ApiRunEventsRoute '/api/send': typeof ApiSendRoute + '/api/session-bundle': typeof ApiSessionBundleRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute '/api/tasks': typeof ApiTasksRoute @@ -174,6 +182,7 @@ export interface FileRoutesById { '/api/repo-context': typeof ApiRepoContextRoute '/api/run-events': typeof ApiRunEventsRoute '/api/send': typeof ApiSendRoute + '/api/session-bundle': typeof ApiSessionBundleRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute '/api/tasks': typeof ApiTasksRoute @@ -196,6 +205,7 @@ export interface FileRouteTypes { | '/api/repo-context' | '/api/run-events' | '/api/send' + | '/api/session-bundle' | '/api/sessions' | '/api/stream' | '/api/tasks' @@ -216,6 +226,7 @@ export interface FileRouteTypes { | '/api/repo-context' | '/api/run-events' | '/api/send' + | '/api/session-bundle' | '/api/sessions' | '/api/stream' | '/api/tasks' @@ -236,6 +247,7 @@ export interface FileRouteTypes { | '/api/repo-context' | '/api/run-events' | '/api/send' + | '/api/session-bundle' | '/api/sessions' | '/api/stream' | '/api/tasks' @@ -257,6 +269,7 @@ export interface RootRouteChildren { ApiRepoContextRoute: typeof ApiRepoContextRoute ApiRunEventsRoute: typeof ApiRunEventsRoute ApiSendRoute: typeof ApiSendRoute + ApiSessionBundleRoute: typeof ApiSessionBundleRoute ApiSessionsRoute: typeof ApiSessionsRoute ApiStreamRoute: typeof ApiStreamRoute ApiTasksRoute: typeof ApiTasksRoute @@ -322,6 +335,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSessionsRouteImport parentRoute: typeof rootRouteImport } + '/api/session-bundle': { + id: '/api/session-bundle' + path: '/api/session-bundle' + fullPath: '/api/session-bundle' + preLoaderRoute: typeof ApiSessionBundleRouteImport + parentRoute: typeof rootRouteImport + } '/api/send': { id: '/api/send' path: '/api/send' @@ -409,6 +429,7 @@ const rootRouteChildren: RootRouteChildren = { ApiRepoContextRoute: ApiRepoContextRoute, ApiRunEventsRoute: ApiRunEventsRoute, ApiSendRoute: ApiSendRoute, + ApiSessionBundleRoute: ApiSessionBundleRoute, ApiSessionsRoute: ApiSessionsRoute, ApiStreamRoute: ApiStreamRoute, ApiTasksRoute: ApiTasksRoute, diff --git a/apps/codex-claw/src/routes/api/session-bundle.ts b/apps/codex-claw/src/routes/api/session-bundle.ts new file mode 100644 index 0000000..4ac369f --- /dev/null +++ b/apps/codex-claw/src/routes/api/session-bundle.ts @@ -0,0 +1,48 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { + getSessionHandoffExport, + isSessionHandoffKind, +} from '../../server/session-bundle' + +function downloadName(filename: string) { + return filename.replace(/[^a-zA-Z0-9._-]/g, '-') +} + +export const Route = createFileRoute('/api/session-bundle')({ + server: { + handlers: { + GET: ({ request }) => { + try { + const url = new URL(request.url) + const rawKind = url.searchParams.get('kind') ?? 'bundle' + const kind = isSessionHandoffKind(rawKind) ? rawKind : 'bundle' + const payload = getSessionHandoffExport({ + kind, + sessionKey: url.searchParams.get('sessionKey') ?? undefined, + friendlyId: url.searchParams.get('friendlyId') ?? undefined, + }) + + if (url.searchParams.get('download') === '1') { + return new Response(payload.markdown, { + headers: { + 'content-type': 'text/markdown; charset=utf-8', + 'content-disposition': + 'attachment; filename="' + downloadName(payload.filename) + '"', + }, + }) + } + + return json(payload) + } catch (err) { + return json( + { + error: err instanceof Error ? err.message : String(err), + }, + { status: 404 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/screens/chat/components/chat-header.tsx b/apps/codex-claw/src/screens/chat/components/chat-header.tsx index 067227b..db4b90a 100644 --- a/apps/codex-claw/src/screens/chat/components/chat-header.tsx +++ b/apps/codex-claw/src/screens/chat/components/chat-header.tsx @@ -8,11 +8,10 @@ import { Settings01Icon, } from '@hugeicons/core-free-icons' import { ContextMeter } from './context-meter' +import type { ExportFormat } from '@/hooks/use-export' import { Button } from '@/components/ui/button' import { ExportMenu } from '@/components/export-menu' -type ExportFormat = 'markdown' | 'json' | 'text' - type ChatHeaderProps = { activeTitle: string wrapperRef?: React.Ref diff --git a/apps/codex-claw/src/server/session-bundle.test.ts b/apps/codex-claw/src/server/session-bundle.test.ts new file mode 100644 index 0000000..a76367d --- /dev/null +++ b/apps/codex-claw/src/server/session-bundle.test.ts @@ -0,0 +1,190 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { resetCodexServerStateForTests } from './codex-cli' +import { + getSessionHandoffExport, + redactHandoffText, +} from './session-bundle' + +describe('session handoff bundles', function () { + const originalStateDir = process.env.CODEX_CLAW_STATE_DIR + const originalWorkdir = process.env.CODEX_CLI_WORKDIR + const originalCommand = process.env.CODEX_CLI_COMMAND + let tempDir = '' + let stateDir = '' + + beforeEach(function () { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'codex-claw-handoff-')) + stateDir = path.join(tempDir, 'state') + mkdirSync(stateDir, { recursive: true }) + process.env.CODEX_CLAW_STATE_DIR = stateDir + process.env.CODEX_CLI_WORKDIR = tempDir + process.env.CODEX_CLI_COMMAND = 'codex-test' + resetCodexServerStateForTests() + }) + + afterEach(function () { + if (originalStateDir === undefined) { + delete process.env.CODEX_CLAW_STATE_DIR + } else { + process.env.CODEX_CLAW_STATE_DIR = originalStateDir + } + if (originalWorkdir === undefined) { + delete process.env.CODEX_CLI_WORKDIR + } else { + process.env.CODEX_CLI_WORKDIR = originalWorkdir + } + if (originalCommand === undefined) { + delete process.env.CODEX_CLI_COMMAND + } else { + process.env.CODEX_CLI_COMMAND = originalCommand + } + resetCodexServerStateForTests() + rmSync(tempDir, { recursive: true, force: true }) + }) + + function writeSessionFixture() { + writeFileSync( + path.join(stateDir, 'sessions.json'), + JSON.stringify({ + version: 1, + sessions: [ + { + key: 'handoff-session', + friendlyId: 'handoff-session', + title: 'Publish alpha safely', + derivedTitle: 'Publish alpha safely', + updatedAt: 300, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: + 'Prepare release notes with OPENAI_API_KEY=sk-testsecret123456789 and C:\\Users\\alice\\repo\\secret.txt', + }, + ], + timestamp: 100, + }, + { + role: 'toolResult', + toolName: 'command_execution', + details: { command: 'pnpm test', exitCode: 0 }, + content: [ + { + type: 'text', + text: 'passed with token ghp_123456789012345678901234', + }, + ], + timestamp: 200, + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Added the release checklist and verified it from ' + tempDir, + }, + ], + timestamp: 300, + }, + ], + }, + ], + }), + ) + writeFileSync( + path.join(stateDir, 'tasks.json'), + JSON.stringify({ + version: 1, + tasks: [ + { + id: 'run-1', + sessionKey: 'handoff-session', + messageId: 'message-1', + prompt: 'private prompt', + message: 'run validation', + status: 'completed', + createdAt: 100, + updatedAt: 200, + startedAt: 120, + finishedAt: 200, + durationMs: 80, + exitCode: 0, + snapshot: { + sessionKey: 'handoff-session', + message: 'run validation', + }, + events: [{ status: 'completed', at: 200 }], + timeline: [ + { + id: 'event-1', + kind: 'tool-result', + at: 180, + relativeMs: 60, + label: 'Tool result', + commandName: 'pnpm test -- GITHUB_TOKEN=ghp_123456789012345678901234', + exitCode: 0, + message: 'validated from ' + tempDir, + }, + ], + }, + ], + }), + ) + resetCodexServerStateForTests() + } + + it('exports a redacted markdown bundle with validation evidence', function () { + writeSessionFixture() + + const payload = getSessionHandoffExport({ + sessionKey: 'handoff-session', + kind: 'bundle', + }) + + expect(payload.filename).toBe('codexclaw-publish-alpha-safely-bundle.md') + expect(payload.markdown).toContain('# CodexClaw session bundle') + expect(payload.markdown).toContain('## Prompt') + expect(payload.markdown).toContain('OPENAI_API_KEY=[REDACTED]') + expect(payload.markdown).toContain('GITHUB_TOKEN=[REDACTED]') + expect(payload.markdown).toContain('pnpm test') + expect(payload.markdown).toContain('exit 0') + expect(payload.markdown).toContain('$WORKSPACE') + expect(payload.markdown).not.toContain('sk-testsecret') + expect(payload.markdown).not.toContain('ghp_123456') + expect(payload.markdown).not.toContain(tempDir) + expect(payload.markdown).not.toContain('C:\\Users\\alice') + }) + + it('generates issue and PR drafts without GitHub writes', function () { + writeSessionFixture() + + const issue = getSessionHandoffExport({ + friendlyId: 'handoff-session', + kind: 'issue', + }) + const pr = getSessionHandoffExport({ + friendlyId: 'handoff-session', + kind: 'pr', + }) + + expect(issue.markdown).toContain('## Expected Outcome') + expect(issue.markdown).toContain('## Redaction Note') + expect(pr.markdown).toContain('## Summary') + expect(pr.markdown).toContain('CodexClaw does not publish it automatically') + }) + + it('redacts generic long secret-looking values', function () { + const redacted = redactHandoffText( + 'SESSION_TOKEN=' + 'a'.repeat(90) + ' npm_123456789012345678901234', + ) + + expect(redacted).toContain('SESSION_TOKEN=[REDACTED]') + expect(redacted).toContain('[REDACTED_TOKEN]') + }) +}) diff --git a/apps/codex-claw/src/server/session-bundle.ts b/apps/codex-claw/src/server/session-bundle.ts new file mode 100644 index 0000000..b518bc8 --- /dev/null +++ b/apps/codex-claw/src/server/session-bundle.ts @@ -0,0 +1,378 @@ +import os from 'node:os' +import path from 'node:path' +import { + getCodexHistory, + getCodexPaths, + getCodexRunEventLog, + listCodexSessions, + listCodexTasks, + resolveCodexSession, +} from './codex-cli' +import { getGitReviewPayload } from './git-review' + +export type SessionHandoffKind = 'bundle' | 'issue' | 'pr' + +export type SessionHandoffExport = { + kind: SessionHandoffKind + title: string + filename: string + markdown: string +} + +type HistoryMessage = ReturnType['messages'][number] +type TaskRecord = ReturnType['tasks'][number] + +type BundleContext = { + title: string + sessionKey: string + friendlyId: string + exportedAt: string + prompt: string + assistantResult: string + toolSummaries: Array + changedFiles: Array + validationCommands: Array +} + +const maxPromptChars = 3000 +const maxResultChars = 4000 +const maxToolChars = 700 +const maxCommandChars = 240 +const markdownCodeTick = String.fromCharCode(96) + +export function isSessionHandoffKind(value: string): value is SessionHandoffKind { + return value === 'bundle' || value === 'issue' || value === 'pr' +} + +export function redactHandoffText(value: string, limit = maxResultChars) { + const paths = getCodexPaths() + const replacements = [ + [path.resolve(paths.stateDir), '$CODEX_CLAW_STATE'], + [path.resolve(paths.workspace.codexWorkdir), '$WORKSPACE'], + [os.homedir(), '~'], + ] as const + let next = value + + for (const [from, to] of replacements) { + if (!from) continue + next = next.split(from).join(to) + } + + next = next.replace( + /\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|ACCESS_KEY|AUTH)[A-Z0-9_]*)\s*[:=]\s*([^\s"']+)/gi, + '$1=[REDACTED]', + ) + next = next.replace( + /\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9_]{20,})\b/g, + '[REDACTED_TOKEN]', + ) + next = next.replace( + /\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g, + '[REDACTED_JWT]', + ) + next = next.replace(/\b[A-Za-z]:\\Users\\[^\s"']+/g, '~\\[REDACTED_PATH]') + next = next.replace(/(?:\/Users|\/home)\/[^\s"']+/g, '~/[REDACTED_PATH]') + next = next.replace(/\b[A-Za-z0-9+/_-]{80,}={0,2}\b/g, '[REDACTED_VALUE]') + + if (next.length <= limit) return next + return ( + next.slice(0, limit).trimEnd() + + '\n\n[Truncated ' + + String(next.length - limit) + + ' redacted characters]' + ) +} + +function textFromMessage(message: HistoryMessage) { + const parts = Array.isArray(message.content) ? message.content : [] + return parts + .map(function mapPart(part) { + if (part.type === 'text') return part.text ?? '' + if (part.type === 'thinking') return part.thinking ?? '' + if (part.type === 'toolCall') { + const name = part.name ? part.name : 'tool' + return 'Tool call: ' + name + } + return '' + }) + .filter(Boolean) + .join('\n') + .trim() +} + +function titleFromSession(sessionKey: string, friendlyId: string) { + const session = listCodexSessions({ includeArchived: true }).sessions.find( + (item) => item.key === sessionKey || item.friendlyId === friendlyId, + ) + return ( + session?.label || + session?.title || + session?.derivedTitle || + friendlyId || + sessionKey || + 'CodexClaw session' + ) +} + +function latestText( + messages: Array, + role: string, + limit: number, +) { + const match = [...messages] + .reverse() + .find((message) => message.role === role && textFromMessage(message)) + return match ? redactHandoffText(textFromMessage(match), limit) : '' +} + +function code(value: string) { + return markdownCodeTick + value + markdownCodeTick +} + +function toolSummariesFromHistory(messages: Array) { + return messages + .filter( + (message) => + message.role === 'toolResult' || + message.role === 'toolCall' || + typeof message.toolName === 'string', + ) + .map(function mapToolMessage(message) { + const toolName = message.toolName || message.role || 'tool' + const status = message.isError ? 'error' : 'ok' + const text = redactHandoffText(textFromMessage(message), maxToolChars) + return code(toolName) + ' ' + status + (text ? ': ' + text : '') + }) +} + +function tasksForSession(sessionKey: string) { + return listCodexTasks().tasks.filter((task) => task.sessionKey === sessionKey) +} + +function runEventsForTask(task: TaskRecord) { + try { + return getCodexRunEventLog({ id: task.id }).events + } catch { + return task.timeline + } +} + +function toolSummariesFromTasks(tasks: Array) { + const summaries: Array = [] + for (const task of tasks) { + const events = runEventsForTask(task) + for (const event of events) { + if (event.kind !== 'tool-call' && event.kind !== 'tool-result') continue + const label = event.commandName || event.label || event.kind + const status = + typeof event.exitCode === 'number' + ? 'exit ' + String(event.exitCode) + : event.status || task.status + const message = event.message + ? ': ' + redactHandoffText(event.message, maxToolChars) + : '' + summaries.push( + code(redactHandoffText(label, maxCommandChars)) + ' ' + status + message, + ) + } + } + return summaries +} + +function changedFileSummaries() { + try { + const payload = getGitReviewPayload() + if (!payload.ok || payload.files.length === 0) return [] + return payload.files.map(function mapFile(file) { + return file.state + ': ' + code(redactHandoffText(file.path, maxCommandChars)) + }) + } catch { + return [] + } +} + +function isValidationCommand(command: string) { + return /\b(test|lint|build|check|typecheck|tsc|vitest|eslint|playwright|audit|pack|smoke)\b/i.test( + command, + ) +} + +function validationCommandsFromTasks(tasks: Array) { + const commands = new Map() + for (const task of tasks) { + const events = runEventsForTask(task) + for (const event of events) { + const command = event.commandName + if (!command || !isValidationCommand(command)) continue + const redactedCommand = redactHandoffText(command, maxCommandChars) + const exit = + typeof event.exitCode === 'number' + ? 'exit ' + String(event.exitCode) + : event.status || task.status + commands.set(redactedCommand, code(redactedCommand) + ' - ' + exit) + } + } + return [...commands.values()] +} + +function uniqueLimited(items: Array, limit: number) { + return [...new Set(items.filter(Boolean))].slice(0, limit) +} + +function markdownList(items: Array, fallback: string) { + if (items.length === 0) return '- ' + fallback + return items.map((item) => '- ' + item).join('\n') +} + +function buildContext(input: { + sessionKey?: string + friendlyId?: string +}): BundleContext { + const friendlyId = input.friendlyId?.trim() ?? '' + let sessionKey = input.sessionKey?.trim() ?? '' + + if (!sessionKey && friendlyId) { + const resolved = resolveCodexSession(friendlyId) + sessionKey = typeof resolved.key === 'string' ? resolved.key : '' + } + + if (!sessionKey) throw new Error('sessionKey or friendlyId required') + + const resolved = resolveCodexSession(sessionKey) + if (!resolved.ok) throw new Error('session not found') + + const history = getCodexHistory({ sessionKey, limit: 200 }) + const messages = history.messages + const tasks = tasksForSession(sessionKey) + const title = titleFromSession(sessionKey, friendlyId || sessionKey) + + return { + title: redactHandoffText(title, 160), + sessionKey, + friendlyId: friendlyId || sessionKey, + exportedAt: new Date().toISOString(), + prompt: latestText(messages, 'user', maxPromptChars), + assistantResult: latestText(messages, 'assistant', maxResultChars), + toolSummaries: uniqueLimited( + [...toolSummariesFromHistory(messages), ...toolSummariesFromTasks(tasks)], + 12, + ), + changedFiles: uniqueLimited(changedFileSummaries(), 20), + validationCommands: uniqueLimited(validationCommandsFromTasks(tasks), 10), + } +} + +function buildBundleMarkdown(context: BundleContext) { + return [ + '# CodexClaw session bundle', + '', + '- Session: ' + context.title, + '- Session key: ' + code(redactHandoffText(context.sessionKey, 160)), + '- Exported: ' + context.exportedAt, + '- Redaction: environment values, tokens, private paths, and large outputs are redacted by default.', + '', + '## Prompt', + '', + context.prompt || '_No user prompt recorded._', + '', + '## Assistant Result', + '', + context.assistantResult || '_No assistant result recorded._', + '', + '## Tool Summaries', + '', + markdownList(context.toolSummaries, 'No tool calls recorded.'), + '', + '## Changed Files', + '', + markdownList(context.changedFiles, 'No local changed files detected.'), + '', + '## Validation Commands', + '', + markdownList( + context.validationCommands, + 'No validation commands detected in this session.', + ), + '', + ].join('\n') +} + +function buildIssueMarkdown(context: BundleContext) { + return [ + '# ' + context.title, + '', + '## Context', + '', + context.prompt || '_No prompt recorded._', + '', + '## Expected Outcome', + '', + context.assistantResult || '_No assistant result recorded._', + '', + '## Evidence', + '', + markdownList(context.toolSummaries, 'No tool calls recorded.'), + '', + '## Validation', + '', + markdownList(context.validationCommands, 'No validation commands detected.'), + '', + '## Redaction Note', + '', + 'Review before posting. The draft redacts environment values, tokens, private paths, and large raw output by default.', + '', + ].join('\n') +} + +function buildPrMarkdown(context: BundleContext) { + return [ + '## Summary', + '', + '- ' + (context.assistantResult || 'Summarize the completed change before posting.'), + '', + '## Changed Files', + '', + markdownList(context.changedFiles, 'No local changed files detected.'), + '', + '## Validation', + '', + markdownList(context.validationCommands, 'No validation commands detected.'), + '', + '## Risk', + '', + '- Review this redacted draft before posting; CodexClaw does not publish it automatically.', + '', + ].join('\n') +} + +function safeFilename(value: string) { + return ( + value + .replace(/[^a-zA-Z0-9 _-]/g, '') + .replace(/\s+/g, '-') + .slice(0, 60) + .toLowerCase() || 'session' + ) +} + +export function getSessionHandoffExport(input: { + sessionKey?: string + friendlyId?: string + kind: SessionHandoffKind +}): SessionHandoffExport { + const context = buildContext(input) + const markdown = + input.kind === 'issue' + ? buildIssueMarkdown(context) + : input.kind === 'pr' + ? buildPrMarkdown(context) + : buildBundleMarkdown(context) + + return { + kind: input.kind, + title: context.title, + filename: + 'codexclaw-' + safeFilename(context.title) + '-' + input.kind + '.md', + markdown, + } +}