From dafe3d45773633b0eb0be9abae94dc55ee99767e Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 05:48:47 -0400 Subject: [PATCH 1/5] Add context attachment preview API --- apps/codex-claw/src/routeTree.gen.ts | 21 + .../src/routes/api/context-preview.ts | 23 + apps/codex-claw/src/routes/api/send.ts | 21 +- .../src/server/context-attachments.test.ts | 110 ++++ .../src/server/context-attachments.ts | 587 ++++++++++++++++++ 5 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 apps/codex-claw/src/routes/api/context-preview.ts create mode 100644 apps/codex-claw/src/server/context-attachments.test.ts create mode 100644 apps/codex-claw/src/server/context-attachments.ts diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index 7ea2a83..5f42d8a 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as ApiPathsRouteImport } from './routes/api/paths' import { Route as ApiMcpHealthRouteImport } from './routes/api/mcp-health' import { Route as ApiHistoryRouteImport } from './routes/api/history' import { Route as ApiGitReviewRouteImport } from './routes/api/git-review' +import { Route as ApiContextPreviewRouteImport } from './routes/api/context-preview' import { Route as ApiArtifactsRouteImport } from './routes/api/artifacts' const NewRoute = NewRouteImport.update({ @@ -101,6 +102,11 @@ const ApiGitReviewRoute = ApiGitReviewRouteImport.update({ path: '/api/git-review', getParentRoute: () => rootRouteImport, } as any) +const ApiContextPreviewRoute = ApiContextPreviewRouteImport.update({ + id: '/api/context-preview', + path: '/api/context-preview', + getParentRoute: () => rootRouteImport, +} as any) const ApiArtifactsRoute = ApiArtifactsRouteImport.update({ id: '/api/artifacts', path: '/api/artifacts', @@ -112,6 +118,7 @@ export interface FileRoutesByFullPath { '/connect': typeof ConnectRoute '/new': typeof NewRoute '/api/artifacts': typeof ApiArtifactsRoute + '/api/context-preview': typeof ApiContextPreviewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -130,6 +137,7 @@ export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/new': typeof NewRoute '/api/artifacts': typeof ApiArtifactsRoute + '/api/context-preview': typeof ApiContextPreviewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -149,6 +157,7 @@ export interface FileRoutesById { '/connect': typeof ConnectRoute '/new': typeof NewRoute '/api/artifacts': typeof ApiArtifactsRoute + '/api/context-preview': typeof ApiContextPreviewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -169,6 +178,7 @@ export interface FileRouteTypes { | '/connect' | '/new' | '/api/artifacts' + | '/api/context-preview' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -187,6 +197,7 @@ export interface FileRouteTypes { | '/connect' | '/new' | '/api/artifacts' + | '/api/context-preview' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -205,6 +216,7 @@ export interface FileRouteTypes { | '/connect' | '/new' | '/api/artifacts' + | '/api/context-preview' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -224,6 +236,7 @@ export interface RootRouteChildren { ConnectRoute: typeof ConnectRoute NewRoute: typeof NewRoute ApiArtifactsRoute: typeof ApiArtifactsRoute + ApiContextPreviewRoute: typeof ApiContextPreviewRoute ApiGitReviewRoute: typeof ApiGitReviewRoute ApiHistoryRoute: typeof ApiHistoryRoute ApiMcpHealthRoute: typeof ApiMcpHealthRoute @@ -345,6 +358,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGitReviewRouteImport parentRoute: typeof rootRouteImport } + '/api/context-preview': { + id: '/api/context-preview' + path: '/api/context-preview' + fullPath: '/api/context-preview' + preLoaderRoute: typeof ApiContextPreviewRouteImport + parentRoute: typeof rootRouteImport + } '/api/artifacts': { id: '/api/artifacts' path: '/api/artifacts' @@ -360,6 +380,7 @@ const rootRouteChildren: RootRouteChildren = { ConnectRoute: ConnectRoute, NewRoute: NewRoute, ApiArtifactsRoute: ApiArtifactsRoute, + ApiContextPreviewRoute: ApiContextPreviewRoute, ApiGitReviewRoute: ApiGitReviewRoute, ApiHistoryRoute: ApiHistoryRoute, ApiMcpHealthRoute: ApiMcpHealthRoute, diff --git a/apps/codex-claw/src/routes/api/context-preview.ts b/apps/codex-claw/src/routes/api/context-preview.ts new file mode 100644 index 0000000..b3025c8 --- /dev/null +++ b/apps/codex-claw/src/routes/api/context-preview.ts @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { previewContextAttachment } from '../../server/context-attachments' + +export const Route = createFileRoute('/api/context-preview')({ + server: { + handlers: { + POST: async ({ request }) => { + try { + const body = await request.json().catch(() => ({})) + return json(await previewContextAttachment(body)) + } catch (err) { + return json( + { + error: err instanceof Error ? err.message : String(err), + }, + { status: 400 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/routes/api/send.ts b/apps/codex-claw/src/routes/api/send.ts index ab2c823..bb251df 100644 --- a/apps/codex-claw/src/routes/api/send.ts +++ b/apps/codex-claw/src/routes/api/send.ts @@ -7,6 +7,10 @@ import { resolveCodexSession, sendCodexPrompt, } from '../../server/codex-cli' +import { + buildContextAttachmentPrompt, + parseContextAttachments, +} from '../../server/context-attachments' import { buildRepositoryContextPrompt } from '../../server/repo-context' import type { RepoContextSelection } from '../../server/repo-context' @@ -141,10 +145,25 @@ export const Route = createFileRoute('/api/send')({ const contextSelections = parseContextSelections( body.contextSelections, ) - const contextBlock = + const parsedContextAttachments = parseContextAttachments( + body.contextAttachments, + ) + if (!parsedContextAttachments.ok) { + return json( + { ok: false, error: parsedContextAttachments.error }, + { status: 400 }, + ) + } + const repositoryContextBlock = contextSelections.length > 0 ? buildRepositoryContextPrompt(contextSelections) : undefined + const attachmentContextBlock = buildContextAttachmentPrompt( + parsedContextAttachments.attachments, + ) + const contextBlock = [repositoryContextBlock, attachmentContextBlock] + .filter(Boolean) + .join('\n\n') if ( !message.trim() && diff --git a/apps/codex-claw/src/server/context-attachments.test.ts b/apps/codex-claw/src/server/context-attachments.test.ts new file mode 100644 index 0000000..88d1122 --- /dev/null +++ b/apps/codex-claw/src/server/context-attachments.test.ts @@ -0,0 +1,110 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' + +import { + buildContextAttachmentPrompt, + parseContextAttachments, + previewContextAttachment, + previewDocumentContextAttachment, +} from './context-attachments' + +function base64(value: string) { + return Buffer.from(value, 'utf8').toString('base64') +} + +describe('context attachments', function () { + it('previews an explicit URL fetch without sending it directly', async function () { + const fetcher = function fetchPreview() { + return Promise.resolve( + new Response( + 'Bug report

Issue

Expected behavior

', + { + headers: { + 'content-type': 'text/html; charset=utf-8', + }, + }, + ), + ) + } + + const preview = await previewContextAttachment( + { kind: 'url', url: 'https://example.com/bug-report' }, + fetcher, + ) + + expect(preview.kind).toBe('url') + expect(preview.title).toBe('Bug report') + expect(preview.source).toBe('https://example.com/bug-report') + expect(preview.text).toContain('Expected behavior') + expect(preview.text).not.toContain('ignored()') + }) + + it('rejects unsupported URL protocols', async function () { + await expect( + previewContextAttachment({ kind: 'url', url: 'file:///etc/passwd' }), + ).rejects.toThrow(/http and https/) + }) + + it('previews markdown and JSON documents from base64 content', function () { + const markdownPreview = previewDocumentContextAttachment({ + kind: 'document', + name: 'handoff.md', + mimeType: 'text/markdown', + content: base64('# Handoff\n\nUse this context.'), + }) + const jsonPreview = previewDocumentContextAttachment({ + kind: 'document', + name: 'payload.json', + mimeType: 'application/json', + content: base64('{"ok":true}'), + }) + + expect(markdownPreview.title).toBe('handoff') + expect(markdownPreview.text).toContain('Use this context.') + expect(jsonPreview.text).toContain('"ok": true') + }) + + it('extracts readable text from a small text-based PDF', function () { + const pdf = [ + '%PDF-1.4', + '1 0 obj', + '<< /Type /Page >>', + 'stream', + 'BT', + '/F1 12 Tf', + '72 720 Td', + '(Alpha PDF context) Tj', + 'ET', + 'endstream', + 'endobj', + '%%EOF', + ].join('\n') + + const preview = previewDocumentContextAttachment({ + kind: 'document', + name: 'alpha.pdf', + mimeType: 'application/pdf', + content: base64(pdf), + }) + + expect(preview.mimeType).toBe('application/pdf') + expect(preview.text).toContain('Alpha PDF context') + }) + + it('parses reviewed attachments and builds a bounded prompt block', function () { + const preview = previewDocumentContextAttachment({ + kind: 'document', + name: 'notes.txt', + mimeType: 'text/plain', + content: base64('Reviewed context only.'), + }) + const parsed = parseContextAttachments([preview]) + + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + const prompt = buildContextAttachmentPrompt(parsed.attachments) + expect(prompt).toContain('URL and document context selected in CodexClaw') + expect(prompt).toContain('notes') + expect(prompt).toContain('Reviewed context only.') + }) +}) diff --git a/apps/codex-claw/src/server/context-attachments.ts b/apps/codex-claw/src/server/context-attachments.ts new file mode 100644 index 0000000..ad44215 --- /dev/null +++ b/apps/codex-claw/src/server/context-attachments.ts @@ -0,0 +1,587 @@ +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import path from 'node:path' + +export type ContextAttachmentKind = 'url' | 'document' + +export type ContextAttachmentPreview = { + id: string + kind: ContextAttachmentKind + title: string + source: string + mimeType: string + sizeBytes: number + estimatedTokens: number + text: string + truncated: boolean +} + +export type ContextAttachmentParseResult = + | { + ok: true + attachments: Array + } + | { + ok: false + error: string + } + +type Fetcher = (input: string, init?: RequestInit) => Promise + +const maxAttachmentCount = 6 +const maxPreviewChars = 24000 +const maxTotalContextChars = 48000 +const maxFetchBytes = 256 * 1024 +const maxDocumentBytes = 256 * 1024 +const maxPdfBytes = 512 * 1024 + +const allowedDocumentMimes = new Set([ + 'application/json', + 'application/pdf', + 'text/markdown', + 'text/plain', +]) + +const allowedFetchMimes = new Set([ + 'application/atom+xml', + 'application/javascript', + 'application/json', + 'application/rss+xml', + 'application/xhtml+xml', + 'application/xml', + 'application/x-ndjson', +]) + +function cleanTitle(value: string) { + return Array.from(normalizeWhitespace(value)) + .filter((character) => { + const code = character.charCodeAt(0) + return code >= 32 && code !== 127 + }) + .join('') + .slice(0, 140) +} + +function normalizeWhitespace(value: string) { + return value.replace(/\s+/g, ' ').trim() +} + +function normalizeText(value: string) { + return value + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/[\t\f\v ]+/g, ' ') + .replace(/ *\n */g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function estimateTokens(text: string) { + return Math.ceil(text.length / 4) +} + +function truncatePreviewText(text: string) { + const normalized = normalizeText(text) + if (normalized.length <= maxPreviewChars) { + return { text: normalized, truncated: false } + } + return { + text: normalized.slice(0, maxPreviewChars).trimEnd() + '\n[truncated]', + truncated: true, + } +} + +function basePreview(input: { + id?: string + kind: ContextAttachmentKind + title: string + source: string + mimeType: string + sizeBytes: number + text: string +}): ContextAttachmentPreview { + const preview = truncatePreviewText(input.text) + const title = cleanTitle(input.title) || cleanTitle(input.source) || 'Context' + return { + id: cleanTitle(input.id ?? '') || randomUUID(), + kind: input.kind, + title, + source: input.source, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + estimatedTokens: estimateTokens(preview.text), + text: preview.text, + truncated: preview.truncated, + } +} + +function mimeBase(value: string) { + return value.split(';')[0]?.trim().toLowerCase() ?? '' +} + +function inferDocumentMimeType(name: string, mimeType: string) { + const normalized = mimeBase(mimeType) + if (normalized && normalized !== 'application/octet-stream') { + return normalized + } + + const extension = path.extname(name).toLowerCase() + if (extension === '.json') return 'application/json' + if (extension === '.md' || extension === '.markdown') return 'text/markdown' + if (extension === '.pdf') return 'application/pdf' + if (extension === '.txt' || extension === '.log') return 'text/plain' + return normalized +} + +function allowedFetchMime(mimeType: string) { + const normalized = mimeBase(mimeType) + if (!normalized) return true + return normalized.startsWith('text/') || allowedFetchMimes.has(normalized) +} + +function allowedDocumentMime(mimeType: string) { + const normalized = mimeBase(mimeType) + return normalized.startsWith('text/') || allowedDocumentMimes.has(normalized) +} + +function titleFromUrl(url: URL) { + const pathname = url.pathname.split('/').filter(Boolean).pop() ?? '' + const decoded = pathname ? decodeURIComponent(pathname) : '' + return cleanTitle(decoded.replace(/[-_]+/g, ' ')) || url.hostname +} + +function titleFromDocument(name: string) { + const base = path.basename(name).replace(/\.[^.]+$/, '') + return cleanTitle(base.replace(/[-_]+/g, ' ')) || 'Document context' +} + +function decodeHtmlEntities(value: string) { + return value.replace( + /&(#x[0-9a-f]+|#\d+|amp|apos|gt|lt|nbsp|quot);/gi, + (_match, entity: string) => { + const normalized = entity.toLowerCase() + if (normalized === 'amp') return '&' + if (normalized === 'apos') return "'" + if (normalized === 'gt') return '>' + if (normalized === 'lt') return '<' + if (normalized === 'nbsp') return ' ' + if (normalized === 'quot') return '"' + if (normalized.startsWith('#x')) { + return String.fromCodePoint(Number.parseInt(normalized.slice(2), 16)) + } + if (normalized.startsWith('#')) { + return String.fromCodePoint(Number.parseInt(normalized.slice(1), 10)) + } + return '' + }, + ) +} + +function extractHtmlTitle(html: string) { + const titleMatch = /]*>([\s\S]*?)<\/title>/i.exec(html) + if (titleMatch?.[1]) return cleanTitle(decodeHtmlEntities(titleMatch[1])) + const headingMatch = /]*>([\s\S]*?)<\/h1>/i.exec(html) + if (headingMatch?.[1]) { + return cleanTitle( + decodeHtmlEntities(headingMatch[1].replace(/<[^>]+>/g, ' ')), + ) + } + return '' +} + +function extractHtmlText(html: string) { + return normalizeText( + decodeHtmlEntities( + html + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace( + /<\/(p|div|section|article|header|footer|li|tr|h[1-6])>/gi, + '\n', + ) + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' '), + ), + ) +} + +function extractTextByMime(text: string, mimeType: string) { + const normalizedMime = mimeBase(mimeType) + if (normalizedMime === 'application/json') { + try { + return JSON.stringify(JSON.parse(text), null, 2) + } catch { + return text + } + } + if ( + normalizedMime === 'text/html' || + normalizedMime === 'application/xhtml+xml' + ) { + return extractHtmlText(text) + } + return normalizeText(text) +} + +function decodeBase64Content(content: string) { + const trimmed = content.trim() + const dataUrlMatch = /^data:[^,]+;base64,(?[\s\S]+)$/i.exec(trimmed) + const base64 = (dataUrlMatch?.groups?.data ?? trimmed).replace(/\s/g, '') + if (!base64 || base64.length % 4 === 1) { + throw new Error('Document content could not be decoded.') + } + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) { + throw new Error('Document content could not be decoded.') + } + return Buffer.from(base64, 'base64') +} + +function decodePdfString(value: string) { + return value + .replace(/\\([nrtbf()\\])/g, (_match, escaped: string) => { + if (escaped === 'n') return '\n' + if (escaped === 'r') return '\n' + if (escaped === 't') return ' ' + if (escaped === 'b' || escaped === 'f') return '' + return escaped + }) + .replace(/\\([0-7]{1,3})/g, (_match, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)), + ) +} + +function extractPdfText(buffer: Buffer) { + if (!buffer.subarray(0, 5).equals(Buffer.from('%PDF-'))) { + throw new Error('PDF context must be a valid PDF file.') + } + + const source = buffer.toString('latin1') + const matches = source.match(/\((?:\\.|[^\\()]){2,}\)/g) ?? [] + const text = matches + .map((match) => decodePdfString(match.slice(1, -1))) + .map((value) => normalizeWhitespace(value)) + .filter((value) => /[a-z0-9]/i.test(value)) + .join('\n') + + if (!text) { + throw new Error( + 'PDF text could not be extracted safely. Try a smaller text-based PDF or convert it to markdown/text.', + ) + } + return text +} + +function documentText(buffer: Buffer, mimeType: string) { + const normalizedMime = mimeBase(mimeType) + if (normalizedMime === 'application/pdf') { + return extractPdfText(buffer) + } + + if (buffer.includes(0)) { + throw new Error( + 'Document appears to be binary. Use markdown, text, JSON, or a small text-based PDF.', + ) + } + + return extractTextByMime(buffer.toString('utf8'), normalizedMime) +} + +async function readResponseBytesLimited(response: Response) { + const contentLength = response.headers.get('content-length') + if (contentLength && Number(contentLength) > maxFetchBytes) { + throw new Error( + 'URL response is too large. Attach a smaller page or document.', + ) + } + + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()) + if (buffer.length > maxFetchBytes) { + throw new Error( + 'URL response is too large. Attach a smaller page or document.', + ) + } + return buffer + } + + const reader = response.body.getReader() + const chunks: Array = [] + let size = 0 + for (;;) { + const result = await reader.read() + if (result.done) break + const chunk = Buffer.from(result.value) + size += chunk.length + if (size > maxFetchBytes) { + throw new Error( + 'URL response is too large. Attach a smaller page or document.', + ) + } + chunks.push(chunk) + } + return Buffer.concat(chunks) +} + +export async function previewUrlContextAttachment( + rawInput: Record, + fetcher: Fetcher = fetch, +): Promise { + const rawUrl = typeof rawInput.url === 'string' ? rawInput.url.trim() : '' + if (!rawUrl) throw new Error('Enter a URL before fetching context.') + + let url: URL + try { + url = new URL(rawUrl) + } catch { + throw new Error('URL context must be a valid http or https URL.') + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('URL context only supports http and https links.') + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 8000) + let response: Response + try { + response = await fetcher(url.toString(), { + headers: { + accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.1', + 'user-agent': 'CodexClaw/0.1 context-preview', + }, + redirect: 'follow', + signal: controller.signal, + }) + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error('URL preview timed out. Try a faster or smaller page.') + } + throw new Error( + 'URL preview failed: ' + + (err instanceof Error ? err.message : String(err)), + ) + } finally { + clearTimeout(timeout) + } + + if (!response.ok) { + throw new Error('URL preview failed with HTTP ' + response.status + '.') + } + + const mimeType = mimeBase(response.headers.get('content-type') ?? '') + if (!allowedFetchMime(mimeType)) { + throw new Error( + 'URL response type is not supported. Use a text, HTML, JSON, or XML page.', + ) + } + + const buffer = await readResponseBytesLimited(response) + if (buffer.includes(0)) { + throw new Error( + 'URL response appears to be binary. Use a text, HTML, JSON, or XML page.', + ) + } + + const rawText = buffer.toString('utf8') + const title = + mimeType === 'text/html' || mimeType === 'application/xhtml+xml' + ? extractHtmlTitle(rawText) || titleFromUrl(url) + : titleFromUrl(url) + const text = extractTextByMime(rawText, mimeType) + if (!text) { + throw new Error('URL preview did not contain readable text.') + } + + return basePreview({ + id: typeof rawInput.id === 'string' ? rawInput.id : undefined, + kind: 'url', + title, + source: url.toString(), + mimeType: mimeType || 'text/plain', + sizeBytes: buffer.length, + text, + }) +} + +export function previewDocumentContextAttachment( + rawInput: Record, +): ContextAttachmentPreview { + const name = typeof rawInput.name === 'string' ? rawInput.name.trim() : '' + const content = + typeof rawInput.content === 'string' ? rawInput.content.trim() : '' + const mimeType = inferDocumentMimeType( + name, + typeof rawInput.mimeType === 'string' ? rawInput.mimeType : '', + ) + + if (!name) throw new Error('Choose a document before previewing context.') + if (!content) throw new Error('Document content could not be decoded.') + if (!allowedDocumentMime(mimeType)) { + throw new Error( + 'Document type is not supported. Use markdown, text, JSON, or a small text-based PDF.', + ) + } + + const buffer = decodeBase64Content(content) + const sizeLimit = + mimeType === 'application/pdf' ? maxPdfBytes : maxDocumentBytes + if (buffer.length > sizeLimit) { + throw new Error( + 'Document is too large. Use a smaller file for prompt context.', + ) + } + + const text = documentText(buffer, mimeType) + if (!text) { + throw new Error('Document preview did not contain readable text.') + } + + return basePreview({ + id: typeof rawInput.id === 'string' ? rawInput.id : undefined, + kind: 'document', + title: titleFromDocument(name), + source: name, + mimeType, + sizeBytes: buffer.length, + text, + }) +} + +export async function previewContextAttachment( + rawInput: unknown, + fetcher: Fetcher = fetch, +): Promise { + if (!rawInput || typeof rawInput !== 'object') { + throw new Error('Context attachment preview requires an object payload.') + } + const input = rawInput as Record + const kind = typeof input.kind === 'string' ? input.kind : '' + if (kind === 'url') return previewUrlContextAttachment(input, fetcher) + if (kind === 'document') return previewDocumentContextAttachment(input) + throw new Error('Context attachment type must be url or document.') +} + +export function parseContextAttachments( + rawAttachments: unknown, +): ContextAttachmentParseResult { + if (typeof rawAttachments === 'undefined') { + return { ok: true, attachments: [] } + } + if (!Array.isArray(rawAttachments)) { + return { ok: false, error: 'contextAttachments must be an array' } + } + if (rawAttachments.length > maxAttachmentCount) { + return { + ok: false, + error: 'Too many context attachments. Send six or fewer at once.', + } + } + + let totalChars = 0 + const attachments: Array = [] + for (const rawAttachment of rawAttachments) { + if (!rawAttachment || typeof rawAttachment !== 'object') { + return { ok: false, error: 'context attachment must be an object' } + } + const attachment = rawAttachment as Record + const kind = + attachment.kind === 'url' + ? 'url' + : attachment.kind === 'document' + ? 'document' + : null + const text = + typeof attachment.text === 'string' ? normalizeText(attachment.text) : '' + const source = + typeof attachment.source === 'string' ? attachment.source.trim() : '' + const title = + typeof attachment.title === 'string' ? cleanTitle(attachment.title) : '' + const mimeType = + typeof attachment.mimeType === 'string' + ? mimeBase(attachment.mimeType) + : '' + const sizeBytes = + typeof attachment.sizeBytes === 'number' && + Number.isFinite(attachment.sizeBytes) + ? Math.max(0, Math.floor(attachment.sizeBytes)) + : 0 + + if (!kind || !title || !source || !mimeType || !text) { + return { + ok: false, + error: + 'Context attachment preview is incomplete. Preview it again before sending.', + } + } + if (text.length > maxPreviewChars + 20) { + return { + ok: false, + error: + 'Context attachment is too large. Preview a smaller source before sending.', + } + } + + totalChars += text.length + if (totalChars > maxTotalContextChars) { + return { + ok: false, + error: 'Context attachments are too large. Remove one before sending.', + } + } + + attachments.push({ + id: + typeof attachment.id === 'string' && attachment.id.trim() + ? cleanTitle(attachment.id) + : randomUUID(), + kind, + title, + source, + mimeType, + sizeBytes, + estimatedTokens: + typeof attachment.estimatedTokens === 'number' && + Number.isFinite(attachment.estimatedTokens) + ? Math.max(0, Math.floor(attachment.estimatedTokens)) + : estimateTokens(text), + text, + truncated: attachment.truncated === true, + }) + } + + return { ok: true, attachments } +} + +export function buildContextAttachmentPrompt( + attachments: Array, +) { + if (attachments.length === 0) return '' + + const blocks = attachments.map((attachment, index) => { + return [ + '--- Context attachment ' + + String(index + 1) + + ': ' + + attachment.title + + ' ---', + 'Source: ' + attachment.source, + 'Type: ' + attachment.mimeType, + 'Size: ' + String(attachment.sizeBytes) + ' bytes', + 'Estimated context: ' + String(attachment.estimatedTokens) + ' tokens', + attachment.truncated ? 'Preview was truncated before send.' : '', + '', + attachment.text, + ] + .filter((line) => line.length > 0) + .join('\n') + }) + + return [ + 'URL and document context selected in CodexClaw:', + 'Attached items: ' + + attachments + .map((attachment) => attachment.title + ' <' + attachment.source + '>') + .join(', '), + ...blocks, + ].join('\n\n') +} From 9055c1d943c73bd6d8d939d40968372488a081ee Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 05:49:20 -0400 Subject: [PATCH 2/5] Add composer context attachment controls --- .../src/screens/chat/chat-queries.ts | 14 + .../src/screens/chat/chat-screen-utils.ts | 30 +- .../src/screens/chat/chat-screen.tsx | 33 +- .../screens/chat/components/chat-composer.tsx | 55 ++- .../components/context-attachment-picker.tsx | 354 ++++++++++++++++++ .../chat/hooks/use-chat-pending-send.ts | 3 + .../src/screens/chat/pending-send.ts | 2 + apps/codex-claw/src/screens/chat/types.ts | 26 ++ 8 files changed, 504 insertions(+), 13 deletions(-) create mode 100644 apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx diff --git a/apps/codex-claw/src/screens/chat/chat-queries.ts b/apps/codex-claw/src/screens/chat/chat-queries.ts index fccdc87..5770558 100644 --- a/apps/codex-claw/src/screens/chat/chat-queries.ts +++ b/apps/codex-claw/src/screens/chat/chat-queries.ts @@ -2,6 +2,8 @@ import { getMessageTimestamp, normalizeSessions, readError } from './utils' import type { QueryClient } from '@tanstack/react-query' import type { ArtifactListResponse, + ContextAttachment, + ContextAttachmentPreviewInput, GatewayMessage, GitReviewPayload, HistoryResponse, @@ -210,6 +212,18 @@ export async function fetchRepoContext( return (await res.json()) as RepoContextPayload } +export async function previewContextAttachment( + input: ContextAttachmentPreviewInput, +): Promise { + const res = await fetch('/api/context-preview', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }) + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as ContextAttachment +} + export async function fetchGitReview(): Promise { const res = await fetch('/api/git-review') if (!res.ok) throw new Error(await readError(res)) diff --git a/apps/codex-claw/src/screens/chat/chat-screen-utils.ts b/apps/codex-claw/src/screens/chat/chat-screen-utils.ts index a81ae9d..bb8ec21 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen-utils.ts +++ b/apps/codex-claw/src/screens/chat/chat-screen-utils.ts @@ -1,4 +1,8 @@ -import type { GatewayMessage, RepoContextSelection } from './types' +import type { + ContextAttachment, + GatewayMessage, + RepoContextSelection, +} from './types' import type { AttachmentFile } from '@/components/attachment-button' import { randomUUID } from '@/lib/utils' @@ -12,6 +16,7 @@ export function createOptimisticMessage( body: string, attachments?: Array, contextSelections?: Array, + contextAttachments?: Array, ): OptimisticMessagePayload { const clientId = randomUUID() const optimisticId = `opt-${clientId}` @@ -40,13 +45,24 @@ export function createOptimisticMessage( if (body.trim()) { content.push({ type: 'text', text: body }) - } else if (contextSelections && contextSelections.length > 0) { - content.push({ - type: 'text', - text: + } else if ( + (contextSelections && contextSelections.length > 0) || + (contextAttachments && contextAttachments.length > 0) + ) { + const labels: Array = [] + if (contextSelections && contextSelections.length > 0) { + labels.push( 'Repository context: ' + - contextSelections.map((selection) => selection.path).join(', '), - }) + contextSelections.map((selection) => selection.path).join(', '), + ) + } + if (contextAttachments && contextAttachments.length > 0) { + labels.push( + 'Context attachments: ' + + contextAttachments.map((attachment) => attachment.title).join(', '), + ) + } + content.push({ type: 'text', text: labels.join('\n') }) } else if (attachments && attachments.length > 0) { content.push({ type: 'text', text: '' }) } diff --git a/apps/codex-claw/src/screens/chat/chat-screen.tsx b/apps/codex-claw/src/screens/chat/chat-screen.tsx index 5403294..fa93285 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen.tsx +++ b/apps/codex-claw/src/screens/chat/chat-screen.tsx @@ -47,7 +47,11 @@ import { shouldRedirectToConnect } from './hooks/use-chat-error-state' import { useChatRedirect } from './hooks/use-chat-redirect' import type { AttachmentFile } from '@/components/attachment-button' import type { ChatComposerHelpers } from './components/chat-composer' -import type { RepoContextSelection, RunProfileId } from './types' +import type { + ContextAttachment, + RepoContextSelection, + RunProfileId, +} from './types' import { useExport } from '@/hooks/use-export' import { useChatSettings } from '@/hooks/use-chat-settings' import { cn, randomUUID } from '@/lib/utils' @@ -240,6 +244,7 @@ export function ChatScreen({ skipOptimistic = false, attachments?: Array, contextSelections?: Array, + contextAttachments?: Array, runProfile?: RunProfileId, confirmedRisk?: boolean, ) { @@ -249,6 +254,7 @@ export function ChatScreen({ body, attachments, contextSelections, + contextAttachments, ) optimisticClientId = clientId appendHistoryMessage( @@ -275,6 +281,17 @@ export function ChatScreen({ content: a.base64, name: a.file.name, })) + const contextAttachmentsPayload = contextAttachments?.map((attachment) => ({ + id: attachment.id, + kind: attachment.kind, + title: attachment.title, + source: attachment.source, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + estimatedTokens: attachment.estimatedTokens, + text: attachment.text, + truncated: attachment.truncated, + })) fetch('/api/send', { method: 'POST', @@ -287,6 +304,7 @@ export function ChatScreen({ idempotencyKey: randomUUID(), attachments: attachmentsPayload, contextSelections, + contextAttachments: contextAttachmentsPayload, runProfile, confirmedRisk, }), @@ -367,19 +385,26 @@ export function ChatScreen({ (body: string, helpers: ChatComposerHelpers) => { const attachments = helpers.attachments const contextSelections = helpers.contextSelections + const contextAttachments = helpers.contextAttachments const runProfile = helpers.runProfile const confirmedRisk = helpers.confirmedRisk if ( body.length === 0 && (!attachments || attachments.length === 0) && - (!contextSelections || contextSelections.length === 0) + (!contextSelections || contextSelections.length === 0) && + (!contextAttachments || contextAttachments.length === 0) ) return helpers.reset() if (isNewChat) { const { clientId, optimisticId, optimisticMessage } = - createOptimisticMessage(body, attachments, contextSelections) + createOptimisticMessage( + body, + attachments, + contextSelections, + contextAttachments, + ) appendHistoryMessage(queryClient, 'new', 'new', optimisticMessage) setPendingGeneration(true) setSending(true) @@ -396,6 +421,7 @@ export function ChatScreen({ optimisticMessage, attachments, contextSelections, + contextAttachments, runProfile, confirmedRisk, }) @@ -438,6 +464,7 @@ export function ChatScreen({ false, attachments, contextSelections, + contextAttachments, runProfile, confirmedRisk, ) diff --git a/apps/codex-claw/src/screens/chat/components/chat-composer.tsx b/apps/codex-claw/src/screens/chat/components/chat-composer.tsx index 1cb8873..7168f16 100644 --- a/apps/codex-claw/src/screens/chat/components/chat-composer.tsx +++ b/apps/codex-claw/src/screens/chat/components/chat-composer.tsx @@ -7,8 +7,17 @@ import { RepoContextPanel, RepoContextSummary, } from './repo-context-picker' +import { + ContextAttachmentButton, + ContextAttachmentPanel, + ContextAttachmentSummary, +} from './context-attachment-picker' import type { Ref } from 'react' -import type { RepoContextSelection, RunProfileId } from '../types' +import type { + ContextAttachment, + RepoContextSelection, + RunProfileId, +} from '../types' import type { AttachmentFile } from '@/components/attachment-button' import { @@ -35,6 +44,7 @@ type ChatComposerHelpers = { setValue: (value: string) => void attachments?: Array contextSelections?: Array + contextAttachments?: Array runProfile?: RunProfileId confirmedRisk?: boolean } @@ -74,6 +84,10 @@ function ChatComposerComponent({ const [contextSelections, setContextSelections] = useState< Array >([]) + const [contextAttachmentOpen, setContextAttachmentOpen] = useState(false) + const [contextAttachments, setContextAttachments] = useState< + Array + >([]) const [runProfile, setRunProfile] = useState('read-only-inspect') const [confirmedRisk, setConfirmedRisk] = useState(false) @@ -101,6 +115,8 @@ function ChatComposerComponent({ }) setContextSelections([]) setContextOpen(false) + setContextAttachments([]) + setContextAttachmentOpen(false) setConfirmedRisk(false) focusPrompt() }, [focusPrompt]) @@ -141,6 +157,11 @@ function ChatComposerComponent({ prev.filter((selection) => selection.path !== path), ) }, []) + const handleRemoveContextAttachment = useCallback((id: string) => { + setContextAttachments((prev) => + prev.filter((attachment) => attachment.id !== id), + ) + }, []) const activeRunProfile = useMemo(() => { return ( runProfiles.find((profile) => profile.id === runProfile) ?? runProfiles[0] @@ -175,12 +196,13 @@ function ChatComposerComponent({ const handleSubmit = useCallback(() => { if (disabled) return const body = valueRef.current.trim() - // Allow submit if there is text, an image, or selected repo context. + // Allow submit if there is text, an image, or selected context. const validAttachments = attachments.filter((a) => !a.error && a.base64) if ( body.length === 0 && validAttachments.length === 0 && - contextSelections.length === 0 + contextSelections.length === 0 && + contextAttachments.length === 0 ) return if (activeRunProfile.requiresConfirmation && !confirmedRisk) return @@ -189,6 +211,7 @@ function ChatComposerComponent({ setValue: setComposerValue, attachments: validAttachments, contextSelections, + contextAttachments, runProfile, confirmedRisk, }) @@ -201,6 +224,7 @@ function ChatComposerComponent({ setComposerValue, attachments, contextSelections, + contextAttachments, runProfile, confirmedRisk, activeRunProfile.requiresConfirmation, @@ -229,11 +253,20 @@ function ChatComposerComponent({ selections={contextSelections} onRemove={handleRemoveContext} /> + + )} /> + ( + + setContextAttachmentOpen((current) => !current) + } + disabled={disabled} + buttonProps={{ + ...triggerProps, + className: cn('rounded-full', triggerProps.className), + }} + /> + )} + /> ( diff --git a/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx b/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx new file mode 100644 index 0000000..8c6beaa --- /dev/null +++ b/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx @@ -0,0 +1,354 @@ +import { useRef, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Cancel01Icon, + FileAttachmentIcon, + FileUploadIcon, + LinkSquare01Icon, + PlusSignIcon, +} from '@hugeicons/core-free-icons' + +import { previewContextAttachment } from '../chat-queries' +import type { ContextAttachment } from '../types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' + +type ContextAttachmentSummaryProps = { + attachments: Array + onRemove: (id: string) => void +} + +type ContextAttachmentPanelProps = { + open: boolean + attachments: Array + onAttachmentsChange: (attachments: Array) => void +} + +const maxAttachmentCount = 6 +const acceptedDocumentTypes = + '.md,.markdown,.txt,.json,.pdf,text/markdown,text/plain,application/json,application/pdf' + +function formatBytes(bytes: number) { + if (bytes < 1024) return String(bytes) + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' +} + +function attachmentIcon(attachment: ContextAttachment) { + return attachment.kind === 'url' ? LinkSquare01Icon : FileAttachmentIcon +} + +function attachmentKey(attachment: ContextAttachment) { + return attachment.id +} + +function fileToBase64(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(new Error('Document could not be read.')) + reader.onload = () => { + const value = typeof reader.result === 'string' ? reader.result : '' + const data = value.includes(',') + ? value.slice(value.indexOf(',') + 1) + : value + if (!data) { + reject(new Error('Document could not be read.')) + return + } + resolve(data) + } + reader.readAsDataURL(file) + }) +} + +function ContextAttachmentPreview({ + attachment, + action, +}: { + attachment: ContextAttachment + action?: React.ReactNode +}) { + return ( +
+
+ +
+
+ {attachment.title} +
+
+ {attachment.source} +
+
+ {formatBytes(attachment.sizeBytes)} + {attachment.estimatedTokens.toLocaleString()} tokens + {attachment.mimeType} + {attachment.truncated ? ( + truncated + ) : null} +
+
+ {action} +
+
+ {attachment.text} +
+
+ ) +} + +export function ContextAttachmentSummary({ + attachments, + onRemove, +}: ContextAttachmentSummaryProps) { + if (attachments.length === 0) return null + + return ( +
+ {attachments.map((attachment) => ( + + + {attachment.title} + + {attachment.estimatedTokens.toLocaleString()} + + + + ))} +
+ ) +} + +export function ContextAttachmentPanel({ + open, + attachments, + onAttachmentsChange, +}: ContextAttachmentPanelProps) { + const fileInputRef = useRef(null) + const [url, setUrl] = useState('') + const [draft, setDraft] = useState(null) + const [loading, setLoading] = useState<'url' | 'document' | null>(null) + const [error, setError] = useState(null) + const atLimit = attachments.length >= maxAttachmentCount + + async function handleUrlPreview() { + const nextUrl = url.trim() + if (!nextUrl || loading) return + setLoading('url') + setError(null) + setDraft(null) + try { + setDraft(await previewContextAttachment({ kind: 'url', url: nextUrl })) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(null) + } + } + + async function handleDocumentChange( + event: React.ChangeEvent, + ) { + const file = event.target.files?.[0] + event.target.value = '' + if (!file || loading) return + setLoading('document') + setError(null) + setDraft(null) + try { + const content = await fileToBase64(file) + setDraft( + await previewContextAttachment({ + kind: 'document', + name: file.name, + mimeType: file.type, + content, + }), + ) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(null) + } + } + + function addDraft() { + if (!draft || atLimit) return + onAttachmentsChange([...attachments, draft]) + setDraft(null) + if (draft.kind === 'url') setUrl('') + } + + if (!open) return null + + return ( +
+
+
+ + setUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void handleUrlPreview() + } + }} + placeholder="https://example.com/reference" + size="sm" + className="min-w-[220px] flex-1" + /> + + void handleDocumentChange(event)} + className="hidden" + aria-hidden="true" + /> + +
+
+ + {attachments.length} / {maxAttachmentCount} attached + + markdown, text, JSON, PDF +
+
+ + {error ?
{error}
: null} + {atLimit ? ( +
+ Remove a context attachment before adding another. +
+ ) : null} + +
+ {draft ? ( + + + Add context + + } + /> + ) : null} + {attachments.map((attachment) => ( + + onAttachmentsChange( + attachments.filter((item) => item.id !== attachment.id), + ) + } + aria-label={'Remove context ' + attachment.title} + > + + + } + /> + ))} +
+
+ ) +} + +export function ContextAttachmentButton({ + open, + onToggle, + disabled, + buttonProps, +}: { + open: boolean + onToggle: () => void + disabled?: boolean + buttonProps?: React.ComponentProps +}) { + return ( + + ) +} diff --git a/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts b/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts index edda96e..d80871c 100644 --- a/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts +++ b/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts @@ -9,6 +9,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { AttachmentFile } from '@/components/attachment-button' import type { + ContextAttachment, HistoryResponse, RepoContextSelection, RunProfileId, @@ -30,6 +31,7 @@ type UseChatPendingSendInput = { skipOptimistic: boolean, attachments?: Array, contextSelections?: Array, + contextAttachments?: Array, runProfile?: RunProfileId, confirmedRisk?: boolean, ) => void @@ -106,6 +108,7 @@ export function useChatPendingSend({ true, pending.attachments, pending.contextSelections, + pending.contextAttachments, pending.runProfile, pending.confirmedRisk, ) diff --git a/apps/codex-claw/src/screens/chat/pending-send.ts b/apps/codex-claw/src/screens/chat/pending-send.ts index a67eb1a..64ca3e6 100644 --- a/apps/codex-claw/src/screens/chat/pending-send.ts +++ b/apps/codex-claw/src/screens/chat/pending-send.ts @@ -1,4 +1,5 @@ import type { + ContextAttachment, GatewayMessage, RepoContextSelection, RunProfileId, @@ -12,6 +13,7 @@ export type PendingSendPayload = { optimisticMessage: GatewayMessage attachments?: Array contextSelections?: Array + contextAttachments?: Array runProfile?: RunProfileId confirmedRisk?: boolean } diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index 1bc58db..bf30fe6 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -189,6 +189,32 @@ export type RepoContextPayload = { estimate: RepoContextEstimate } +export type ContextAttachmentKind = 'url' | 'document' + +export type ContextAttachment = { + id: string + kind: ContextAttachmentKind + title: string + source: string + mimeType: string + sizeBytes: number + estimatedTokens: number + text: string + truncated: boolean +} + +export type ContextAttachmentPreviewInput = + | { + kind: 'url' + url: string + } + | { + kind: 'document' + name: string + mimeType: string + content: string + } + export type GitFileState = 'staged' | 'unstaged' | 'untracked' | 'deleted' export type GitReviewFile = { From 2a6557372e5a2a1932064d27016553a8b85c1788 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 05:53:36 -0400 Subject: [PATCH 3/5] Normalize context attachment icon sizing --- .../src/screens/chat/components/context-attachment-picker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx b/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx index 8c6beaa..2d56d7b 100644 --- a/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx +++ b/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx @@ -118,7 +118,7 @@ export function ContextAttachmentSummary({ > @@ -132,7 +132,7 @@ export function ContextAttachmentSummary({ onClick={() => onRemove(attachment.id)} aria-label={'Remove context ' + attachment.title} > - + ))} From 98354c2854af6d6de1e636131ba4cc10aecbab76 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 05:58:18 -0400 Subject: [PATCH 4/5] Tighten context preview HTML filtering --- apps/codex-claw/src/server/context-attachments.test.ts | 2 +- apps/codex-claw/src/server/context-attachments.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/codex-claw/src/server/context-attachments.test.ts b/apps/codex-claw/src/server/context-attachments.test.ts index 88d1122..d43b1ea 100644 --- a/apps/codex-claw/src/server/context-attachments.test.ts +++ b/apps/codex-claw/src/server/context-attachments.test.ts @@ -17,7 +17,7 @@ describe('context attachments', function () { const fetcher = function fetchPreview() { return Promise.resolve( new Response( - 'Bug report

Issue

Expected behavior

', + 'Bug report

Issue

Expected behavior

', { headers: { 'content-type': 'text/html; charset=utf-8', diff --git a/apps/codex-claw/src/server/context-attachments.ts b/apps/codex-claw/src/server/context-attachments.ts index ad44215..4e95b25 100644 --- a/apps/codex-claw/src/server/context-attachments.ts +++ b/apps/codex-claw/src/server/context-attachments.ts @@ -193,8 +193,8 @@ function extractHtmlText(html: string) { return normalizeText( decodeHtmlEntities( html - .replace(//gi, ' ') - .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(//gi, ' ') .replace( /<\/(p|div|section|article|header|footer|li|tr|h[1-6])>/gi, '\n', From 8ac096f5790fa9818f574a25070ece749691510b Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 06:01:55 -0400 Subject: [PATCH 5/5] Harden malformed HTML tag filtering --- apps/codex-claw/src/server/context-attachments.test.ts | 2 +- apps/codex-claw/src/server/context-attachments.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/codex-claw/src/server/context-attachments.test.ts b/apps/codex-claw/src/server/context-attachments.test.ts index d43b1ea..ac5ce28 100644 --- a/apps/codex-claw/src/server/context-attachments.test.ts +++ b/apps/codex-claw/src/server/context-attachments.test.ts @@ -17,7 +17,7 @@ describe('context attachments', function () { const fetcher = function fetchPreview() { return Promise.resolve( new Response( - 'Bug report

Issue

Expected behavior

', + 'Bug report

Issue

Expected behavior