Created
July 30, 2025 11:40
-
-
Save heguro/39677d935c592672e2ec78bb1076786f to your computer and use it in GitHub Desktop.
claude codeの会話履歴から無くてもいい部分を除いてMarkdown出力
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* eslint-disable no-undef */ | |
| /* eslint-disable tsdoc/syntax */ | |
| import { execSync } from "child_process"; | |
| import fs from "fs"; | |
| import { stringify } from "javascript-stringify"; | |
| // import path from "path"; | |
| // Read/Grepツールのidを記録するためのセット | |
| const skipToolResultIds = new Set(); | |
| // ToolResultを出力から除外するツール名 | |
| const skipToolResultTypes = new Set(["Read", "Grep", "TodoWrite", "LS", "mcp__postgres__query", "Bash"]); | |
| /** @param {string} result */ | |
| function trimToolResult(result) { | |
| return result | |
| .replace(/\s+Here's the result of running `cat -n` on a snippet of the edited file:[\s\S]+/, "") | |
| // eslint-disable-next-line no-control-regex | |
| .replace(/\x1b\[([0-9]{1,3}((;[0-9]{1,3})*)?)?[mGK]/g, "") | |
| .trim(); | |
| } | |
| /** @param {string} str */ | |
| function quote(str) { | |
| return "> " + str.trim().replace(/\n/g, "\n> ").replace(/\n> (?=\n)/g, "\n>"); | |
| } | |
| function formatContent(contentArray) { | |
| let markdown = ""; | |
| // contentArrayが配列でない場合(例: 初期のユーザー入力テキスト)、配列にラップする | |
| const content = Array.isArray(contentArray) ? contentArray : [{ type: "text", text: contentArray }]; | |
| content.forEach((item) => { | |
| switch (item.type) { | |
| case "thinking": | |
| markdown += `> *思考:*\n${quote(item.thinking)}\n\n`; | |
| break; | |
| case "tool_use": | |
| // スキップ対象のツールタイプの場合はidを記録 | |
| if (skipToolResultTypes.has(item.name)) { | |
| skipToolResultIds.add(item.id); | |
| } | |
| switch (item.name) { | |
| case "ExitPlanMode": | |
| case "exit_plan_mode": { | |
| markdown += `ツール使用: \`${item.id}\` (${item.name})\\\n`; | |
| markdown += `**計画:**\n\n${quote(item.input.plan)}\n\n`; | |
| break; | |
| } | |
| case "TodoWrite": { | |
| markdown += `ツール使用: \`${item.id}\` (${item.name})\\\n`; | |
| // {id:'1',content:'SnwMarkCommonServiceImpl.javaの呼び出し元を遡って調査',status:'pending|in_progress|completed'} | |
| // `[x] name` 形式 にする | |
| markdown += "TODO:\n"; | |
| markdown += item.input.todos.map((todo) => { | |
| const status = todo.status === "completed" ? "x" : " "; | |
| return `- [${status}] ${todo.content}${todo.status === "in_progress" ? " (in_progress)" : ""}`; | |
| }).join("\n"); | |
| break; | |
| } | |
| case "Edit": | |
| case "MultiEdit": { | |
| markdown += `ツール使用: \`${item.id}\` (${item.name})\\\n`; | |
| // tool_result で結果が出るため何も出力しない | |
| break; | |
| } | |
| default: { | |
| markdown += `<details><summary>ツール使用: \`${item.id}\` (${item.name}) </summary>\n\n`; | |
| markdown += "**ツール入力:**\n"; | |
| markdown += `\`\`\`js\n${stringify(item.input)}\n\`\`\`\n\n`; | |
| markdown += "</details>\n"; | |
| } | |
| } | |
| break; | |
| case "tool_result": { | |
| const result = item; // alias for clarity | |
| // スキップ対象のツールタイプ | |
| if (skipToolResultIds.has(result.tool_use_id)) { | |
| break; | |
| } | |
| markdown += `<details><summary>ツール結果: \`${result.tool_use_id}\` </summary>\n\n`; | |
| // tool_resultのcontentがオブジェクトの場合と、直接is_errorを持つ場合を考慮 | |
| const resultContents = Array.isArray(result.content) ? result.content : [result.content]; | |
| for (const resultContent of resultContents) { | |
| const isError = result.is_error || (resultContent && resultContent.is_error); | |
| if (isError) { | |
| markdown += " (エラー)\n"; | |
| markdown += `\`\`\`js\n${stringify(resultContent)}\n\`\`\`\n\n`; | |
| } else { | |
| markdown += "\n"; | |
| // Handle different content types within tool_result's content | |
| if (resultContent?.stdout) { | |
| markdown += `* **標準出力:**\n \`\`\`\`text\n ${resultContent.stdout.trim()}\n \`\`\`\`\n`; | |
| } | |
| if (resultContent?.stderr) { | |
| markdown += `* **標準エラー出力:**\n \`\`\`\`text\n ${resultContent.stderr.trim()}\n \`\`\`\`\n`; | |
| } | |
| if (resultContent?.file) { | |
| markdown += `* **ファイル:** \`${resultContent.file.filePath}\`\n`; | |
| // Optionally display file content snippet | |
| const fileContentSnippet = resultContent.file.content.substring(0, 500); // 増やしました | |
| markdown += ` \`\`\`\`text\n ${fileContentSnippet}${resultContent.file.content.length > 500 ? "..." : ""}\n \`\`\`\`\n`; | |
| } | |
| if (resultContent?.text) { // tool_result.content.text の場合 | |
| try { | |
| const jsonContent = JSON.parse(resultContent.text); | |
| markdown += `\`\`\`js\n${stringify(jsonContent)}\n\`\`\`\n`; | |
| } catch (e) { | |
| markdown += `\`\`\`\`\n${resultContent.text.trim()}\n\`\`\`\`\n`; | |
| } | |
| } | |
| // If content is a simple string for successful tool_result | |
| if (typeof resultContent === "string") { | |
| markdown += `\`\`\`\`\n${trimToolResult(resultContent)}\n\`\`\`\`\n`; | |
| } | |
| markdown += "\n"; | |
| } | |
| } | |
| markdown += "</details>\n"; | |
| break; | |
| } | |
| case "text": | |
| default: // Treat anything else as text | |
| if (typeof item.text === "string") { | |
| markdown += `${item.text.trim()}\n\n`; | |
| } else { | |
| // Fallback for unexpected object types, stringify them | |
| markdown += `\`\`\`js\n${stringify(item)}\n\`\`\`\n\n`; | |
| } | |
| break; | |
| } | |
| }); | |
| return markdown.trim(); // Remove trailing newlines | |
| } | |
| function formatPatch(toolUseResult) { | |
| if (!toolUseResult.structuredPatch) { | |
| return ""; | |
| } | |
| let patchMarkdown = `\n<details><summary>ファイル変更結果: ${toolUseResult.filePath}</summary>\n\n`; | |
| toolUseResult.structuredPatch.forEach((patch) => { | |
| patchMarkdown += "```diff\n"; | |
| patchMarkdown += `--- ${toolUseResult.filePath}\n`; | |
| patchMarkdown += `+++ ${toolUseResult.filePath}\n`; | |
| patchMarkdown += `@@ -${patch.oldStart},${patch.oldLines} +${patch.newStart},${patch.newLines} @@\n`; | |
| patch.lines.forEach((line) => { | |
| patchMarkdown += `${line}\n`; | |
| }); | |
| patchMarkdown += "```\n\n"; | |
| }); | |
| patchMarkdown += "</details>\n"; | |
| return patchMarkdown; | |
| } | |
| // --- Main Processing Logic --- | |
| let inputFilePath = process.argv[2]; // Get file path from command line argument | |
| if (!inputFilePath) { | |
| console.error(`Usage: | |
| # 指定したJSONLファイルからMarkdown形式の会話ログを生成 | |
| node script.js <path_to_jsonl_file> | |
| # 最近の会話一覧を表示 (10件) | |
| node script.js --list | |
| # リストする数を指定 | |
| node script.js --list=<count> | |
| # 最近の会話一覧のn番目から会話ログを生成 | |
| node script.js --no=<number> | |
| `); | |
| process.exit(1); | |
| } | |
| if (inputFilePath.startsWith("--list")) { | |
| const listCount = parseInt(inputFilePath.replace(/\D/g, "")) || 10; | |
| // ~/.claude/projects の中の全ての *.jsonl ファイルから新しい順に表示 | |
| try { | |
| // 更新日時順にファイルを取得 | |
| const command = `find ~/.claude/projects -type f -name "*.jsonl" -printf '%T@ %p\\n' | sort -nr | head -n ${listCount} | cut -d' ' -f2-`; | |
| const stdout = execSync(command, { encoding: "utf-8" }); | |
| const files = stdout.trim().split("\n").filter((f) => f); | |
| if (files.length > 0) { | |
| for (const [fileIndex, filepath] of files.entries()) { | |
| // 更新日時取得 | |
| const stats = fs.statSync(filepath); | |
| const lastModified = stats.mtime; | |
| const lastModifiedStr = lastModified.toLocaleString(); | |
| // 概要を表示 | |
| const fileContent = fs.readFileSync(filepath, "utf-8"); | |
| const lines = fileContent.split("\n").filter((line) => line.trim() !== ""); | |
| const data = lines.map((line) => JSON.parse(line)); | |
| const summaryEntry = data.findLast((entry) => entry.type === "summary"); | |
| const info = `${fileIndex + 1} ${filepath} (${data.length} entry, modified: ${lastModifiedStr})`; | |
| if (summaryEntry) { | |
| console.log(info, ":", summaryEntry.summary); | |
| } else { | |
| console.log(info); | |
| } | |
| let outputLines = 0; | |
| for (const entry of data) { | |
| if (entry.type === "summary") continue; | |
| const contentOrArray = entry.message?.content; | |
| try { | |
| const content = Array.isArray(contentOrArray) ? contentOrArray[0] : contentOrArray; | |
| const isString = typeof content === "string"; | |
| if (isString && content.startsWith("Caveat: ")) continue; | |
| const text = isString ? content : content.thinking || content.text || stringify(content); | |
| if (text.startsWith("<command-name>")) continue; | |
| if (text.startsWith("<local-command-stdout>")) continue; | |
| console.log(" " + text.replaceAll("\n", " ").slice(0, 150)); | |
| } catch { | |
| // console.error("Error processing entry:", entry.message || entry); | |
| continue; | |
| } | |
| outputLines++; | |
| if (outputLines >= 3) break; | |
| } | |
| console.log(); | |
| } | |
| } else { | |
| console.log("No .jsonl files found in ~/.claude/projects."); | |
| } | |
| } catch (error) { | |
| console.error("Error listing recent files:", error.stderr || error.message); | |
| } | |
| process.exit(0); | |
| } | |
| if (inputFilePath.startsWith("--no")) { | |
| const listNo = parseInt(inputFilePath.replace(/\D/g, "")) || 10; | |
| // ~/.claude/projects の中の全ての *.jsonl ファイルから新しい順に取得 | |
| const command = `find ~/.claude/projects -type f -name "*.jsonl" -printf '%T@ %p\\n' | sort -nr | head -n ${listNo} | cut -d' ' -f2-`; | |
| const stdout = execSync(command, { encoding: "utf-8" }); | |
| const files = stdout.trim().split("\n").filter((f) => f); | |
| inputFilePath = files[listNo - 1] || ""; | |
| if (!inputFilePath) { | |
| console.error("not found"); | |
| process.exit(1); | |
| } | |
| } | |
| /** @type {string[]} */ | |
| let lines; | |
| try { | |
| const fileContent = fs.readFileSync(inputFilePath, "utf-8"); | |
| lines = fileContent.split("\n").filter((line) => line.trim() !== ""); | |
| const data = lines.map((line) => JSON.parse(line)); | |
| let markdownOutput = `# 会話ログ (Session ID: ${data.find((d) => d.sessionId)?.sessionId || "N/A"})\n\n`; | |
| let lastParentUuidForAssistant = null; // アシスタントの親UUIDを追跡するための変数 | |
| data.forEach((entry, index) => { | |
| if (entry.type === "user") { | |
| // ユーザーメッセージのUUIDを次のアシスタントメッセージの親として設定 | |
| lastParentUuidForAssistant = entry.uuid; | |
| const userTurn = ` | |
| ${formatContent(entry.message.content)} | |
| ${entry.toolUseResult?.structuredPatch ? formatPatch(entry.toolUseResult) : ""}`; | |
| if (userTurn.trim() === "") { | |
| // ユーザーメッセージが空の場合はスキップ | |
| return; | |
| } | |
| markdownOutput += ` | |
| ## 👤 User Turn | |
| ${userTurn} | |
| `; | |
| } else if (entry.type === "assistant") { | |
| // アシスタントメッセージが親UUIDを持つ場合、または直前のユーザーメッセージに対する応答の場合 | |
| // 厳密には parentUuid を使うのが正確だが、単純な親子関係ならこれで良い | |
| const isResponseToLastUser = entry.parentUuid === lastParentUuidForAssistant; | |
| markdownOutput += ` | |
| ## 🤖 Assistant Turn`; | |
| if (!isResponseToLastUser && entry.parentUuid) { | |
| // 明示的にparentUuidがあるが、直前のユーザーメッセージとは異なる場合(例: サイドチェーン) | |
| // markdownOutput += ` (Continuation of \`${entry.parentUuid.substring(0, 8)}...\`)`; | |
| } | |
| markdownOutput += ` | |
| ${formatContent(entry.message.content)} | |
| `; | |
| // アシスタントメッセージの後は、次のアシスタントメッセージが続く可能性があるため、 | |
| // lastParentUuidForAssistant はクリアしない。 | |
| // もしユーザーのツール結果もユーザーメッセージとして扱われる場合、 | |
| // そのtool_use_idをparentUuidとして持つアシスタントメッセージもあるため、 | |
| // このロジックはより複雑になる。現状のJSONLを見る限り、ユーザーからのtool_resultも | |
| // userメッセージのcontent内に含まれているので、これで良いはず。 | |
| } | |
| // 他のタイプは無視するか、必要に応じてここに追加 | |
| }); | |
| // Clean up the final output (remove extra blank lines) | |
| markdownOutput = markdownOutput.replace(/\n{3,}/g, "\n\n"); | |
| // Output to console (or save to a file) | |
| console.log(markdownOutput); | |
| // To save to a file: | |
| // fs.writeFileSync('output.md', markdownOutput, 'utf-8'); | |
| // console.log('Markdown saved to output.md'); | |
| } catch (error) { | |
| console.error("Error processing file:", error); | |
| if (error.line) { | |
| console.error(`Error on line ${error.line}: ${lines[error.line - 1]}`); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment