From 4ee2b8edf707b200973e19d1486fe6450c64fcb6 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:25:54 -0400 Subject: [PATCH] feat(claude): ConversationWriter writes session-file JSONL `ConversationWriter::write_conversation(&conv, w)` writes a conversation in Claude Code session-file layout: preamble lines, then entries, one JSON value per line, newline-terminated. It is the inverse of `ConversationReader::read_conversation`. Records go straight to the sink via `serde_json::to_writer`, so no intermediate String exists. The trailing newline is part of the contract: Claude Code appends to the file on resume, and without it the first appended entry lands on the last line. `p export claude` writes through the new writer; its private serialization loop in cmd_export.rs becomes a three-line adapter that collects the bytes into a String for stdout and --output. Bumps toolpath-claude to 0.12.3 (additive). --- CHANGELOG.md | 4 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/src/cmd_export.rs | 15 ++---- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/lib.rs | 2 + crates/toolpath-claude/src/writer.rs | 78 ++++++++++++++++++++++++++++ site/_data/crates.json | 2 +- 8 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 crates/toolpath-claude/src/writer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bfaf316b..4f815230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the Toolpath workspace are documented here. +## toolpath-claude 0.12.3 — 2026-08-21 + +- `ConversationWriter::write_conversation(&conv, w)` writes a conversation in Claude Code session-file layout: preamble lines, then entries, one JSON value per line, newline-terminated. It is the write-side counterpart of `ConversationReader::read_conversation`. `p export claude` writes through it. + ## toolpath 0.7.1 — 2026-08-14 Adds an optional `description` field to `StepMeta`, `PathMeta`, and diff --git a/Cargo.lock b/Cargo.lock index 24b36aeb..5d4972df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4226,7 +4226,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.2" +version = "0.12.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d823e0e2..23b8bddf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.1", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.12.3", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index b95a67f0..68805fd5 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -706,18 +706,9 @@ fn build_claude_conversation(path: &toolpath::v1::Path) -> Result Result { - let mut lines = Vec::with_capacity(conv.preamble.len() + conv.entries.len()); - for raw in &conv.preamble { - lines.push(serde_json::to_string(raw)?); - } - for entry in &conv.entries { - lines.push(serde_json::to_string(entry)?); - } - // Trailing newline matters: Claude Code appends to this file on resume, - // and without it the first appended entry lands on the last line. - let mut out = lines.join("\n"); - out.push('\n'); - Ok(out) + let mut buf = Vec::new(); + toolpath_claude::ConversationWriter::write_conversation(conv, &mut buf)?; + Ok(String::from_utf8(buf).expect("serde_json emits UTF-8")) } #[cfg(not(target_os = "emscripten"))] diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index acb66c2f..a5c1caf3 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.2" +version = "0.12.3" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index 7251769e..0abf3c3c 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -14,6 +14,7 @@ pub mod reader; pub mod types; #[cfg(feature = "watcher")] pub mod watcher; +pub mod writer; #[cfg(feature = "watcher")] pub use async_watcher::{AsyncConversationWatcher, WatcherConfig, WatcherHandle}; @@ -30,6 +31,7 @@ pub use types::{ }; #[cfg(feature = "watcher")] pub use watcher::ConversationWatcher; +pub use writer::ConversationWriter; /// High-level interface for reading Claude conversations. /// diff --git a/crates/toolpath-claude/src/writer.rs b/crates/toolpath-claude/src/writer.rs new file mode 100644 index 00000000..f46f95f6 --- /dev/null +++ b/crates/toolpath-claude/src/writer.rs @@ -0,0 +1,78 @@ +use crate::error::Result; +use crate::types::Conversation; +use std::io::Write; + +pub struct ConversationWriter; + +impl ConversationWriter { + /// Writes `conv` as Claude Code session-file JSONL: preamble + /// lines, then entries, one JSON value per line, each line + /// newline-terminated. + pub fn write_conversation(conv: &Conversation, mut w: W) -> Result<()> { + // Trailing newline matters: Claude Code appends to this file on resume, + // and without it the first appended entry lands on the last line. + for raw in &conv.preamble { + serde_json::to_writer(&mut w, raw)?; + w.write_all(b"\n")?; + } + for entry in &conv.entries { + serde_json::to_writer(&mut w, entry)?; + w.write_all(b"\n")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::reader::ConversationReader; + use crate::types::ConversationEntry; + use serde_json::Value; + + fn conversation() -> Conversation { + let mut convo = Conversation::new("test-session".to_string()); + convo + .preamble + .push(serde_json::json!({"type": "summary", "summary": "s"})); + let entries = [ + r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#, + r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#, + ]; + for entry_json in entries { + let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap(); + convo.add_entry(entry); + } + convo + } + + #[test] + fn one_record_per_line_newline_terminated() { + let convo = conversation(); + + let mut buf = Vec::new(); + ConversationWriter::write_conversation(&convo, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + + assert!(out.ends_with('\n')); + assert!(!out.ends_with("\n\n")); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 3); + assert!(lines[0].contains("\"summary\"")); + for line in &lines { + serde_json::from_str::(line).unwrap(); + } + } + + #[test] + fn round_trips_through_the_reader() { + let convo = conversation(); + let file = tempfile::NamedTempFile::new().unwrap(); + ConversationWriter::write_conversation(&convo, file.as_file()).unwrap(); + + let back = ConversationReader::read_conversation(file.path()).unwrap(); + let entries = |c: &Conversation| serde_json::to_value(&c.entries).unwrap(); + assert_eq!(entries(&back), entries(&convo)); + assert_eq!(back.preamble, convo.preamble); + } +} diff --git a/site/_data/crates.json b/site/_data/crates.json index 898a045c..8f9ea791 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.2", + "version": "0.12.3", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude",