diff --git a/bun.lock b/bun.lock index bd57bddb1d..0f238022a5 100644 --- a/bun.lock +++ b/bun.lock @@ -237,6 +237,7 @@ "version": "1.0.0", "dependencies": { "@claude-code-best/agent-tools": "workspace:*", + "marked": "^17.0.6", }, }, "packages/cloud-artifacts": { diff --git a/packages/builtin-tools/package.json b/packages/builtin-tools/package.json index 88dac6d49a..dec40e5330 100644 --- a/packages/builtin-tools/package.json +++ b/packages/builtin-tools/package.json @@ -11,6 +11,7 @@ "./utils": "./src/utils.ts" }, "dependencies": { - "@claude-code-best/agent-tools": "workspace:*" + "@claude-code-best/agent-tools": "workspace:*", + "marked": "^17.0.6" } } diff --git a/packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts b/packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts index f3c2604598..3114287b54 100644 --- a/packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts +++ b/packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts @@ -10,13 +10,16 @@ import { } from './prompt.js' import { getArtifactsToken, getUploadUrl } from './config.js' import { uploadArtifact } from './client.js' +import { markdownToHtml } from './markdown.js' import { renderToolResultMessage } from './UI.js' const inputSchema = lazySchema(() => z.strictObject({ file_path: z .string() - .describe('Absolute path to a local HTML file to upload.'), + .describe( + 'Absolute path to a local HTML (.html/.htm) or Markdown (.md/.markdown) file to upload. Markdown is converted to styled HTML before upload.', + ), hash: z .string() .regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$') @@ -47,7 +50,7 @@ export type ArtifactOutput = z.infer export const ArtifactTool = buildTool({ name: ARTIFACT_TOOL_NAME, searchHint: - 'upload html artifact share url cloud publish progress report public link', + 'upload html markdown artifact share url cloud publish progress report public link', maxResultSizeChars: 2_000, shouldDefer: true, strict: true, @@ -146,9 +149,9 @@ export const ArtifactTool = buildTool({ } } - let html: string + let rawContent: string try { - html = await readFile(file_path, 'utf8') + rawContent = await readFile(file_path, 'utf8') } catch { return { data: { @@ -160,6 +163,23 @@ export const ArtifactTool = buildTool({ } } + const lowerPath = file_path.toLowerCase() + let html: string + if (lowerPath.endsWith('.html') || lowerPath.endsWith('.htm')) { + html = rawContent + } else if (lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown')) { + html = markdownToHtml(rawContent, file_path) + } else { + return { + data: { + id: '', + url: '', + expiresAt: '', + error: `Unsupported file extension. Accepted: .html, .htm, .md, .markdown — got: ${file_path}`, + }, + } + } + try { const result = await uploadArtifact({ html, diff --git a/packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts index d8d00f7309..28020740f2 100644 --- a/packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts +++ b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/ArtifactTool.test.ts @@ -6,6 +6,8 @@ import { ArtifactTool } from '../ArtifactTool.js' const TEST_DIR = join(tmpdir(), 'artifact-tool-test') const TEST_FILE = join(TEST_DIR, 'report.html') +const MD_FILE = join(TEST_DIR, 'report.md') +const TXT_FILE = join(TEST_DIR, 'notes.txt') const MISSING_FILE = join(TEST_DIR, 'does-not-exist.html') const DIR_AS_FILE = TEST_DIR @@ -26,6 +28,12 @@ describe('ArtifactTool.call', () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }) writeFileSync(TEST_FILE, '

test report

', 'utf8') + writeFileSync( + MD_FILE, + '# MD Report\n\n| a | b |\n| - | - |\n| 1 | 2 |\n', + 'utf8', + ) + writeFileSync(TXT_FILE, 'plain text content', 'utf8') process.env.CLAUDE_ARTIFACTS_TOKEN = 'test-token' process.env.CLAUDE_ARTIFACTS_URL = 'https://example.test' }) @@ -109,4 +117,48 @@ describe('ArtifactTool.call', () => { 'payload_too_large', ) }) + + test('converts .md file to styled HTML before upload', async () => { + const fetchMock = mockFetchSuccess({ + id: 'md-id', + url: 'https://example.test/7d/md-id.html', + expiresAt: '2026-06-27T10:00:00.000Z', + }) + globalThis.fetch = fetchMock + + const result = await ArtifactTool.call({ file_path: MD_FILE, ttl: 7 }) + + expect(result.data).toMatchObject({ id: 'md-id' }) + expect((result.data as { error?: string }).error).toBeUndefined() + + const calls = ( + fetchMock as unknown as { + mock: { calls: [string | URL | Request, RequestInit | undefined][] } + } + ).mock.calls + expect(calls.length).toBe(1) + const body = String(calls[0][1]?.body) + expect(body.startsWith('')).toBe(true) + expect(body).toContain('

MD Report

') + expect(body).toContain('') + expect(body).toContain('MD Report') + // Content-Type header must remain text/html — the Worker only accepts HTML. + expect(calls[0][1]?.headers).toMatchObject({ 'Content-Type': 'text/html' }) + }) + + test('rejects unsupported extension without calling fetch', async () => { + let fetchCalled = false + globalThis.fetch = mock(() => { + fetchCalled = true + return Promise.resolve(new Response('{}')) + }) as unknown as typeof fetch + + const result = await ArtifactTool.call({ file_path: TXT_FILE, ttl: 7 }) + + expect(fetchCalled).toBe(false) + expect((result.data as { error?: string }).error).toContain( + 'Unsupported file extension', + ) + expect((result.data as { error?: string }).error).toContain('.md') + }) }) diff --git a/packages/builtin-tools/src/tools/ArtifactTool/__tests__/markdown.test.ts b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/markdown.test.ts new file mode 100644 index 0000000000..c07d79fdf3 --- /dev/null +++ b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/markdown.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test' +import { markdownToHtml } from '../markdown.js' + +describe('markdownToHtml', () => { + test('wraps body in a full HTML document', () => { + const out = markdownToHtml('# Hello') + expect(out.startsWith('')).toBe(true) + expect(out).toContain('') + expect(out).toContain(' + + +${body} + + + + +` +} + +function escapeHtml(s: string): string { + return s.replace( + /[&<>"]/g, + c => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + })[c] as string, + ) +} diff --git a/packages/builtin-tools/src/tools/ArtifactTool/prompt.ts b/packages/builtin-tools/src/tools/ArtifactTool/prompt.ts index ab0ebe5f13..b65e003c43 100644 --- a/packages/builtin-tools/src/tools/ArtifactTool/prompt.ts +++ b/packages/builtin-tools/src/tools/ArtifactTool/prompt.ts @@ -1,14 +1,14 @@ export const ARTIFACT_TOOL_NAME = 'artifact' export async function describeArtifactTool(): Promise { - return 'Upload an HTML file to the cloud-artifacts hosting service and get back a public URL. Pass `hash` to overwrite a previously-uploaded artifact (keeps URL stable).' + return 'Upload an HTML or Markdown file to the cloud-artifacts hosting service and get back a public URL. Markdown files are converted to styled HTML before upload. Pass `hash` to overwrite a previously-uploaded artifact (keeps URL stable).' } export async function getArtifactToolPrompt(): Promise { - return `Upload an HTML file to a public hosting service and return a shareable URL plus an internal \`id\` (the "hash"). + return `Upload an HTML or Markdown file to a public hosting service and return a shareable URL plus an internal \`id\` (the "hash"). ## Inputs -- \`file_path\` (required): absolute path to a local HTML file. +- \`file_path\` (required): absolute path to a local HTML (\`.html\`/\`.htm\`) or Markdown (\`.md\`/\`.markdown\`) file. Markdown is converted to a styled HTML document before upload — just author plain Markdown (headings, lists, GFM tables, fenced code blocks, blockquotes) and the tool wraps it in a page with a neutral stylesheet. - \`hash\` (optional): if provided, overwrites the artifact with the same hash (URL stays the same). If omitted, a new random id is generated. - \`ttl\` (optional, default \`7\`): artifact lifetime in days. Must be \`7\` or \`30\`. @@ -16,10 +16,10 @@ export async function getArtifactToolPrompt(): Promise { \`{ id, url, expiresAt }\` — \`id\` is the hash (save it for future overwrite calls), \`url\` is publicly accessible. ## Workflow -1. Use the Write tool to create a local HTML file. +1. Use the Write tool to create a local \`.html\` or \`.md\` file. 2. Call this tool with its \`file_path\`. 3. If iterating on the same artifact, pass back the \`id\` returned from the first call as \`hash\` so the URL stays stable. ## Errors -The tool surfaces backend error codes verbatim (e.g. \`payload_too_large\`, \`unauthorized\`). If the file does not exist or is not a regular file, the tool returns an \`error\` field without making an HTTP request.` +The tool surfaces backend error codes verbatim (e.g. \`payload_too_large\`, \`unauthorized\`). If the file does not exist, is not a regular file, or has an unsupported extension, the tool returns an \`error\` field without making an HTTP request. Accepted extensions: \`.html\`, \`.htm\`, \`.md\`, \`.markdown\`.` } diff --git a/src/constants/system.ts b/src/constants/system.ts index 0cd2e7652d..a2d7ca1d77 100644 --- a/src/constants/system.ts +++ b/src/constants/system.ts @@ -58,7 +58,7 @@ function isAttributionHeaderEnabled(): boolean { /** * Get attribution header for API requests. - * Returns a header string with cc_version (including fingerprint) and cc_entrypoint. + * Returns a header string with cc_version and cc_entrypoint. * Enabled by default, can be disabled via env var or GrowthBook killswitch. * * When NATIVE_CLIENT_ATTESTATION is enabled, includes a `cch=00000` placeholder. @@ -70,22 +70,18 @@ function isAttributionHeaderEnabled(): boolean { * We use a placeholder (instead of injecting from Zig) because same-length * replacement avoids Content-Length changes and buffer reallocation. */ -export function getAttributionHeader(fingerprint: string): string { +export function getAttributionHeader(): string { if (!isAttributionHeaderEnabled()) { return '' } - const version = `${MACRO.VERSION}.${fingerprint}` + const version = MACRO.VERSION const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? 'unknown' // cch=00000 placeholder is overwritten by Bun's HTTP stack with attestation token const cch = feature('NATIVE_CLIENT_ATTESTATION') ? ' cch=00000;' : '' // cc_workload: turn-scoped hint so the API can route e.g. cron-initiated - // requests to a lower QoS pool. Absent = interactive default. Safe re: - // fingerprint (computed from msg chars + version only, line 78 above) and - // cch attestation (placeholder overwritten in serialized body bytes after - // this string is built). Server _parse_cc_header tolerates unknown extra - // fields so old API deploys silently ignore this. + // requests to a lower QoS pool. Absent = interactive default. const workload = getWorkload() const workloadPair = workload ? ` cc_workload=${workload};` : '' const header = `x-anthropic-billing-header: cc_version=${version}; cc_entrypoint=${entrypoint};${cch}${workloadPair}` diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 91db156e6d..5c75e22221 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -72,7 +72,6 @@ import { import { resolveAppliedEffort } from '../../utils/effort.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { errorMessage } from '../../utils/errors.js' -import { computeFingerprintFromMessages } from '../../utils/fingerprint.js' import { captureAPIRequest, logError } from '../../utils/log.js' import { createAssistantAPIErrorMessage, @@ -1386,11 +1385,6 @@ async function* queryModel( postNormalizedMessageCount: messagesForAPI.length, }) - // Compute fingerprint from first user message for attribution. - // Must run BEFORE injecting synthetic messages (e.g. deferred tool names) - // so the fingerprint reflects the actual user input. - const fingerprint = computeFingerprintFromMessages(messagesForAPI) - // When the delta attachment is enabled, deferred tools are announced // via persisted deferred_tools_delta attachments instead of this // ephemeral prepend (which busts cache whenever the pool changes). @@ -1438,7 +1432,7 @@ async function* queryModel( // filter(Boolean) works by converting each element to a boolean - empty strings become false and are filtered out. systemPrompt = asSystemPrompt( [ - getAttributionHeader(fingerprint), + getAttributionHeader(), getCLISyspromptPrefix({ isNonInteractive: options.isNonInteractiveSession, hasAppendSystemPrompt: options.hasAppendSystemPrompt, diff --git a/src/skills/bundled/useArtifacts.ts b/src/skills/bundled/useArtifacts.ts index 4fb819fb2d..021ed8b38b 100644 --- a/src/skills/bundled/useArtifacts.ts +++ b/src/skills/bundled/useArtifacts.ts @@ -36,20 +36,25 @@ Artifacts are public HTML pages you upload to a hosting service. They have stabl **First upload (creates a new artifact):** \`\`\` -1. Use the Write tool to write HTML to a local file (location is your choice). +1. Use the Write tool to write HTML (.html) or Markdown (.md) to a local file (location is your choice). 2. SearchExtraTools({ query: "select:artifact" }) // loads the tool schema -3. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: ".html" } }) +3. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: ".html|.md" } }) 4. Save the returned \`id\` from the tool result — this is the hash. \`\`\` **Subsequent updates (overwrites in place, URL stays stable):** \`\`\` -1. Update the local HTML file. -2. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: ".html", hash: "" } }) +1. Update the local file. +2. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: ".html|.md", hash: "" } }) \`\`\` The URL returned on every call is the same when you pass the same \`hash\`. The user can open it at any time to see the latest version. +## Choosing HTML vs Markdown + +- **Markdown (.md)** — preferred for text-heavy reports, design docs, research notes. The tool converts it to a styled HTML page (GFM tables, fenced code blocks, blockquotes, headings). You write content, not chrome. +- **HTML (.html)** — preferred when you need bespoke layout, custom CSS, embedded SVG, or interactive \`