Skip to content

Instantly share code, notes, and snippets.

@heguro
Created July 30, 2025 11:40
Show Gist options
  • Select an option

  • Save heguro/39677d935c592672e2ec78bb1076786f to your computer and use it in GitHub Desktop.

Select an option

Save heguro/39677d935c592672e2ec78bb1076786f to your computer and use it in GitHub Desktop.
claude codeの会話履歴から無くてもいい部分を除いてMarkdown出力
/* 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