notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit bd946490063b690987facfab6b20dbde48686055
parent 0e209c2bba52c78e529342e74a08ae9238ec0b9e
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 15 Feb 2026 22:02:58 -0800

session_events: lossless round-trip with seq, split, tool-id, cwd tags

Add monotonic seq tag for unambiguous event ordering, split tag for
multi-event assistant messages, tool-id tag pairing tool_call/result
events, and cwd tag. Source-data now stores raw JSONL verbatim (no path
normalization) and is only included on the first event of split groups.

Add session_reconstructor module that uses ndb.fold to reconstruct
original JSONL from stored events. Deduplicate get_tag_value into
session_events for reuse. Include async round-trip integration test
verifying JSONL → events → ndb → JSONL equality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 1+
Mcrates/notedeck_dave/Cargo.toml | 1+
Mcrates/notedeck_dave/src/lib.rs | 3++-
Mcrates/notedeck_dave/src/path_normalize.rs | 22++++++++++++----------
Mcrates/notedeck_dave/src/session_converter.rs | 7+++----
Mcrates/notedeck_dave/src/session_events.rs | 283++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/notedeck_dave/src/session_jsonl.rs | 25++++++++++++++-----------
Mcrates/notedeck_dave/src/session_loader.rs | 41++++++++---------------------------------
Acrates/notedeck_dave/src/session_reconstructor.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 380 insertions(+), 89 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4164,6 +4164,7 @@ dependencies = [ "serde_json", "sha2", "similar", + "tempfile", "tokio", "tracing", "uuid", diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml @@ -40,6 +40,7 @@ objc2-app-kit = { version = "0.3.1", features = ["NSApplication", "NSResponder", [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } +tempfile = { workspace = true } [[bin]] name = "notedeck-spawn" diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -9,14 +9,15 @@ pub(crate) mod git_status; pub mod ipc; pub(crate) mod mesh; mod messages; -mod quaternion; mod path_normalize; +mod quaternion; pub mod session; pub mod session_converter; pub mod session_discovery; pub mod session_events; pub mod session_jsonl; pub mod session_loader; +pub mod session_reconstructor; mod tools; mod ui; mod update; diff --git a/crates/notedeck_dave/src/path_normalize.rs b/crates/notedeck_dave/src/path_normalize.rs @@ -10,6 +10,9 @@ /// Replace all occurrences of `cwd` prefix in absolute paths with relative paths. /// +/// Not currently used (Phase 1 stores raw paths), kept for future Phase 2. +#[allow(dead_code)] +/// /// For example, with cwd = "/Users/jb55/dev/notedeck": /// "/Users/jb55/dev/notedeck/src/main.rs" → "src/main.rs" /// "/Users/jb55/dev/notedeck" → "." @@ -37,6 +40,9 @@ pub fn normalize_paths(json: &str, cwd: &str) -> String { /// Note: This is not perfectly inverse — it will also expand any unrelated /// "." occurrences that happen to match. In practice, the cwd field is the /// main target, and relative paths in tool inputs/outputs are the rest. +/// +/// Not currently used (Phase 1 stores raw paths), kept for future Phase 2. +#[allow(dead_code)] pub fn denormalize_paths(json: &str, local_cwd: &str) -> String { if local_cwd.is_empty() { return json.to_string(); @@ -77,12 +83,10 @@ mod tests { #[test] fn test_normalize_absolute_paths() { - let json = r#"{"cwd":"/Users/jb55/dev/notedeck","file":"/Users/jb55/dev/notedeck/src/main.rs"}"#; + let json = + r#"{"cwd":"/Users/jb55/dev/notedeck","file":"/Users/jb55/dev/notedeck/src/main.rs"}"#; let normalized = normalize_paths(json, "/Users/jb55/dev/notedeck"); - assert_eq!( - normalized, - r#"{"cwd":".","file":"src/main.rs"}"# - ); + assert_eq!(normalized, r#"{"cwd":".","file":"src/main.rs"}"#); } #[test] @@ -112,7 +116,8 @@ mod tests { #[test] fn test_normalize_multiple_occurrences() { - let json = r#"{"old":"/Users/jb55/dev/notedeck/a.rs","new":"/Users/jb55/dev/notedeck/b.rs"}"#; + let json = + r#"{"old":"/Users/jb55/dev/notedeck/a.rs","new":"/Users/jb55/dev/notedeck/b.rs"}"#; let normalized = normalize_paths(json, "/Users/jb55/dev/notedeck"); assert_eq!(normalized, r#"{"old":"a.rs","new":"b.rs"}"#); } @@ -121,10 +126,7 @@ mod tests { fn test_denormalize_cwd_field() { let json = r#"{"cwd":"."}"#; let denormalized = denormalize_paths(json, "/Users/jb55/dev/notedeck"); - assert_eq!( - denormalized, - r#"{"cwd":"/Users/jb55/dev/notedeck"}"# - ); + assert_eq!(denormalized, r#"{"cwd":"/Users/jb55/dev/notedeck"}"#); } #[test] diff --git a/crates/notedeck_dave/src/session_converter.rs b/crates/notedeck_dave/src/session_converter.rs @@ -5,7 +5,7 @@ use crate::session_events::{self, BuiltEvent, ThreadingState}; use crate::session_jsonl::JsonlLine; -use nostrdb::{Ndb, IngestMetadata}; +use nostrdb::{IngestMetadata, Ndb}; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; @@ -30,9 +30,8 @@ pub fn convert_session_to_events( continue; } - let parsed = JsonlLine::parse(&line).map_err(|e| { - ConvertError::Parse(format!("line {}: {}", line_num + 1, e)) - })?; + let parsed = JsonlLine::parse(&line) + .map_err(|e| ConvertError::Parse(format!("line {}: {}", line_num + 1, e)))?; let events = session_events::build_events(&parsed, &mut threading, secret_key) .map_err(|e| ConvertError::Build(format!("line {}: {}", line_num + 1, e)))?; diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -4,14 +4,30 @@ //! mixed content (text + tool_use blocks) are split into separate events. //! Events are threaded using NIP-10 `e` tags with root/reply markers. -use crate::path_normalize; use crate::session_jsonl::{self, ContentBlock, JsonlLine}; -use nostrdb::{NoteBuilder, NoteBuildOptions}; +use nostrdb::{NoteBuildOptions, NoteBuilder}; use std::collections::HashMap; /// Nostr event kind for AI conversation notes. pub const AI_CONVERSATION_KIND: u32 = 1988; +/// Extract the value of a named tag from a note. +pub fn get_tag_value<'a>(note: &'a nostrdb::Note<'a>, tag_name: &str) -> Option<&'a str> { + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + let Some(name) = tag.get_str(0) else { + continue; + }; + if name != tag_name { + continue; + } + return tag.get_str(1); + } + None +} + /// A built nostr event ready for ingestion, with its note ID. #[derive(Debug)] pub struct BuiltEvent { @@ -29,6 +45,14 @@ pub struct ThreadingState { root_note_id: Option<[u8; 32]>, /// The note ID of the most recently built event. last_note_id: Option<[u8; 32]>, + /// Monotonic sequence counter for unambiguous ordering. + seq: u32, +} + +impl Default for ThreadingState { + fn default() -> Self { + Self::new() + } } impl ThreadingState { @@ -37,9 +61,15 @@ impl ThreadingState { uuid_to_note_id: HashMap::new(), root_note_id: None, last_note_id: None, + seq: 0, } } + /// The current sequence number. + pub fn seq(&self) -> u32 { + self.seq + } + /// Record a built event's note ID, associated with a JSONL uuid. fn record(&mut self, uuid: Option<&str>, note_id: [u8; 32]) { if self.root_note_id.is_none() { @@ -49,6 +79,7 @@ impl ThreadingState { self.uuid_to_note_id.insert(uuid.to_string(), note_id); } self.last_note_id = Some(note_id); + self.seq += 1; } } @@ -69,9 +100,7 @@ pub fn build_events( // Check if this is an assistant message with multiple content blocks // that should be split into separate events let blocks: Vec<ContentBlock<'_>> = if is_assistant { - msg.as_ref() - .map(|m| m.content_blocks()) - .unwrap_or_default() + msg.as_ref().map(|m| m.content_blocks()).unwrap_or_default() } else { vec![] }; @@ -80,19 +109,27 @@ pub fn build_events( if should_split { // Build one event per content block - let mut events = Vec::with_capacity(blocks.len()); - for block in &blocks { + let total = blocks.len(); + let mut events = Vec::with_capacity(total); + for (i, block) in blocks.iter().enumerate() { let content = session_jsonl::display_content_for_block(block); let role = match block { ContentBlock::Text(_) => "assistant", ContentBlock::ToolUse { .. } => "tool_call", ContentBlock::ToolResult { .. } => "tool_result", }; + let tool_id = match block { + ContentBlock::ToolUse { id, .. } => Some(*id), + ContentBlock::ToolResult { tool_use_id, .. } => Some(*tool_use_id), + _ => None, + }; let event = build_single_event( line, &content, role, + Some((i, total)), + tool_id, threading, secret_key, )?; @@ -105,10 +142,26 @@ pub fn build_events( let content = session_jsonl::extract_display_content(line); let role = line.role().unwrap_or("unknown"); + // Extract tool_id from single-block messages + let tool_id = msg.as_ref().and_then(|m| { + let blocks = m.content_blocks(); + if blocks.len() == 1 { + match &blocks[0] { + ContentBlock::ToolUse { id, .. } => Some(id.to_string()), + ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.to_string()), + _ => None, + } + } else { + None + } + }); + let event = build_single_event( line, &content, role, + None, + tool_id.as_deref(), threading, secret_key, )?; @@ -133,10 +186,17 @@ impl std::fmt::Display for EventBuildError { } /// Build a single nostr event from a JSONL line. +/// +/// `split_index`: `Some((i, total))` when this event is part of a split +/// assistant message. Only the first event in a split group gets source-data. +/// +/// `tool_id`: The tool use/result ID for tool_call and tool_result events. fn build_single_event( line: &JsonlLine, content: &str, role: &str, + split_index: Option<(usize, usize)>, + tool_id: Option<&str>, threading: &ThreadingState, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { @@ -176,6 +236,10 @@ fn build_single_event( .tag_str("reply"); } + // -- Sequence number (monotonic, for unambiguous ordering) -- + let seq_str = threading.seq.to_string(); + builder = builder.start_tag().tag_str("seq").tag_str(&seq_str); + // -- Message metadata tags -- builder = builder.start_tag().tag_str("source").tag_str("claude-code"); @@ -196,29 +260,42 @@ fn build_single_event( } if let Some(line_type) = line.line_type() { - builder = builder - .start_tag() - .tag_str("turn-type") - .tag_str(line_type); + builder = builder.start_tag().tag_str("turn-type").tag_str(line_type); + } + + // -- CWD tag -- + if let Some(cwd) = line.cwd() { + builder = builder.start_tag().tag_str("cwd").tag_str(cwd); + } + + // -- Split tag (for split assistant messages) -- + if let Some((i, total)) = split_index { + let split_str = format!("{}/{}", i, total); + builder = builder.start_tag().tag_str("split").tag_str(&split_str); + } + + // -- Tool ID tag -- + if let Some(tid) = tool_id { + builder = builder.start_tag().tag_str("tool-id").tag_str(tid); } // -- Discoverability -- - builder = builder - .start_tag() - .tag_str("t") - .tag_str("ai-conversation"); + builder = builder.start_tag().tag_str("t").tag_str("ai-conversation"); // -- Source data (lossless) -- - let raw_json = line.to_json(); - let source_data = if let Some(cwd) = line.cwd() { - path_normalize::normalize_paths(&raw_json, cwd) - } else { - raw_json + // Only include source-data on non-split events or first event of a split group. + // Store raw JSON verbatim (no path normalization). + let include_source_data = match split_index { + Some((i, _)) => i == 0, + None => true, }; - builder = builder - .start_tag() - .tag_str("source-data") - .tag_str(&source_data); + if include_source_data { + let raw_json = line.to_json(); + builder = builder + .start_tag() + .tag_str("source-data") + .tag_str(&raw_json); + } // Sign and build let note = builder @@ -342,7 +419,7 @@ mod tests { } #[test] - fn test_path_normalization_in_source_data() { + fn test_source_data_preserves_raw_json() { let line = JsonlLine::parse( r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/Users/jb55/dev/notedeck","version":"2.0.64","message":{"role":"user","content":"check /Users/jb55/dev/notedeck/src/main.rs"}}"#, ) @@ -351,13 +428,10 @@ mod tests { let mut threading = ThreadingState::new(); let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); - // The source-data tag should have normalized paths let json = &events[0].json; - // Should NOT contain the absolute cwd path in source-data - // (it's normalized to ".") assert!(json.contains("source-data")); - // The source-data value should have relative paths - // This is a basic check — the full round-trip test will verify this properly + // Raw paths should be preserved (no normalization) + assert!(json.contains("/Users/jb55/dev/notedeck")); } #[test] @@ -374,4 +448,153 @@ mod tests { let json = &events[0].json; assert!(json.contains("queue-operation")); } + + #[test] + fn test_seq_counter_increments() { + let lines = vec![ + r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, + r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#, + ]; + + let mut threading = ThreadingState::new(); + let sk = test_secret_key(); + + assert_eq!(threading.seq(), 0); + + let line = JsonlLine::parse(lines[0]).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(threading.seq(), 1); + // First event should have seq=0 + assert!(events[0].json.contains(r#""seq","0"#)); + + let line = JsonlLine::parse(lines[1]).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(threading.seq(), 2); + // Second event should have seq=1 + assert!(events[0].json.contains(r#""seq","1"#)); + } + + #[test] + fn test_split_tags_and_source_data() { + let line = JsonlLine::parse( + r#"{"type":"assistant","uuid":"u3","sessionId":"sess1","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/test.rs"}}]}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + assert_eq!(events.len(), 2); + + // First event (text): split 0/2, has source-data + assert!(events[0].json.contains(r#""split","0/2"#)); + assert!(events[0].json.contains("source-data")); + + // Second event (tool_call): split 1/2, NO source-data, has tool-id + assert!(events[1].json.contains(r#""split","1/2"#)); + assert!(!events[1].json.contains("source-data")); + assert!(events[1].json.contains(r#""tool-id","t1"#)); + } + + #[test] + fn test_cwd_tag() { + let line = JsonlLine::parse( + r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/Users/jb55/dev/notedeck","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + + assert!(events[0] + .json + .contains(r#""cwd","/Users/jb55/dev/notedeck"#)); + } + + #[test] + fn test_tool_result_has_tool_id() { + let line = JsonlLine::parse( + r#"{"type":"user","uuid":"u4","parentUuid":"u3","cwd":"/tmp","sessionId":"s","version":"2.0.64","timestamp":"2026-02-09T20:00:03Z","message":{"role":"user","content":[{"tool_use_id":"toolu_abc","type":"tool_result","content":"file contents"}]}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + assert_eq!(events.len(), 1); + assert!(events[0].json.contains(r#""tool-id","toolu_abc"#)); + } + + #[tokio::test] + async fn test_full_roundtrip() { + use crate::session_reconstructor; + use nostrdb::{Config, IngestMetadata, Ndb, Transaction}; + use serde_json::Value; + use tempfile::TempDir; + + // Sample JSONL lines covering different message types + let jsonl_lines = vec![ + r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:00:00Z","sessionId":"roundtrip-test"}"#, + r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":"Human: hello world\n\n"}}"#, + r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:02Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check that file."},{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/tmp/project/main.rs"}}]}}"#, + r#"{"type":"user","uuid":"u3","parentUuid":"u2","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:03Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"fn main() {}"}]}}"#, + r#"{"type":"assistant","uuid":"u4","parentUuid":"u3","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:04Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"That's a simple main function."}]}}"#, + ]; + + // Set up ndb + let tmp_dir = TempDir::new().unwrap(); + let ndb = Ndb::new(tmp_dir.path().to_str().unwrap(), &Config::new()).unwrap(); + + // Build and ingest events one at a time, waiting for each + let sk = test_secret_key(); + let mut threading = ThreadingState::new(); + let mut total_events = 0; + + let filter = nostrdb::Filter::new() + .kinds([AI_CONVERSATION_KIND as u64]) + .build(); + + for line_str in &jsonl_lines { + let line = JsonlLine::parse(line_str).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + for event in &events { + let sub_id = ndb.subscribe(&[filter.clone()]).unwrap(); + ndb.process_event_with(&event.json, IngestMetadata::new().client(true)) + .expect("ingest failed"); + let _keys = ndb.wait_for_notes(sub_id, 1).await.unwrap(); + total_events += 1; + } + } + + // The split assistant message (line 3) produces 2 events, + // others produce 1 each = 4 + 2 = 6 + assert_eq!(total_events, 6); + + // Reconstruct JSONL from ndb + let txn = Transaction::new(&ndb).unwrap(); + let reconstructed = + session_reconstructor::reconstruct_jsonl_lines(&ndb, &txn, "roundtrip-test").unwrap(); + + // Should get back one JSONL line per original line + assert_eq!( + reconstructed.len(), + jsonl_lines.len(), + "expected {} lines, got {}", + jsonl_lines.len(), + reconstructed.len() + ); + + // Compare each line as serde_json::Value for order-independent equality + for (i, (original, reconstructed)) in + jsonl_lines.iter().zip(reconstructed.iter()).enumerate() + { + let orig_val: Value = serde_json::from_str(original).unwrap(); + let recon_val: Value = serde_json::from_str(reconstructed).unwrap(); + assert_eq!( + orig_val, recon_val, + "line {} mismatch.\noriginal: {}\nreconstructed: {}", + i, original, reconstructed + ); + } + } } diff --git a/crates/notedeck_dave/src/session_jsonl.rs b/crates/notedeck_dave/src/session_jsonl.rs @@ -239,9 +239,7 @@ pub fn extract_display_content(line: &JsonlLine) -> String { .iter() .filter_map(|b| match b { ContentBlock::ToolResult { content, .. } => match content { - Value::String(s) => { - Some(truncate_str(s, 500)) - } + Value::String(s) => Some(truncate_str(s, 500)), _ => Some("[tool result]".to_string()), }, _ => None, @@ -262,11 +260,7 @@ pub fn extract_display_content(line: &JsonlLine) -> String { if let Some(msg) = line.message() { // For assistant messages, we'll produce content for each block. // The caller handles splitting into multiple events for mixed content. - if let Some(text) = msg.text_content() { - text - } else { - String::new() - } + msg.text_content().unwrap_or_default() } else { String::new() } @@ -330,7 +324,10 @@ mod tests { let msg = parsed.message().unwrap(); assert_eq!(msg.role(), Some("user")); - assert_eq!(msg.text_content(), Some("Human: Hello world\n\n".to_string())); + assert_eq!( + msg.text_content(), + Some("Human: Hello world\n\n".to_string()) + ); let content = extract_display_content(&parsed); assert_eq!(content, "Hello world\n\n"); @@ -365,7 +362,10 @@ mod tests { ContentBlock::ToolUse { id, name, input } => { assert_eq!(*id, "toolu_123"); assert_eq!(*name, "Read"); - assert_eq!(input.get("file_path").unwrap().as_str(), Some("/tmp/test.rs")); + assert_eq!( + input.get("file_path").unwrap().as_str(), + Some("/tmp/test.rs") + ); } _ => panic!("expected ToolUse block"), } @@ -439,7 +439,10 @@ mod tests { assert_eq!(blocks.len(), 2); // First block is text - assert!(matches!(blocks[0], ContentBlock::Text("Here is what I found:"))); + assert!(matches!( + blocks[0], + ContentBlock::Text("Here is what I found:") + )); // Second block is tool use match &blocks[1] { diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -5,7 +5,7 @@ //! for populating the chat UI. use crate::messages::{AssistantMessage, ToolResult}; -use crate::session_events::AI_CONVERSATION_KIND; +use crate::session_events::{get_tag_value, AI_CONVERSATION_KIND}; use crate::Message; use nostrdb::{Filter, Ndb, Transaction}; @@ -13,11 +13,7 @@ use nostrdb::{Filter, Ndb, Transaction}; /// /// Returns messages in chronological order, suitable for populating /// `ChatSession.chat` before streaming begins. -pub fn load_session_messages( - ndb: &Ndb, - txn: &Transaction, - session_id: &str, -) -> Vec<Message> { +pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> Vec<Message> { let filter = Filter::new() .kinds([AI_CONVERSATION_KIND as u64]) .tags([session_id], 'd') @@ -32,9 +28,7 @@ pub fn load_session_messages( // Collect notes with their created_at for sorting let mut notes: Vec<_> = results .iter() - .filter_map(|qr| { - ndb.get_note_by_key(txn, qr.note_key).ok() - }) + .filter_map(|qr| ndb.get_note_by_key(txn, qr.note_key).ok()) .collect(); // Sort by created_at (chronological order) @@ -45,13 +39,11 @@ pub fn load_session_messages( let content = note.content(); let role = get_tag_value(note, "role"); - let msg = match role.as_deref() { + let msg = match role { Some("user") => Some(Message::User(content.to_string())), - Some("assistant") => { - Some(Message::Assistant(AssistantMessage::from_text( - content.to_string(), - ))) - } + Some("assistant") => Some(Message::Assistant(AssistantMessage::from_text( + content.to_string(), + ))), Some("tool_call") => { // Tool calls are displayed as assistant messages in the UI Some(Message::Assistant(AssistantMessage::from_text( @@ -63,10 +55,7 @@ pub fn load_session_messages( // Content format is the tool output text let tool_name = "tool".to_string(); let summary = truncate(content, 100); - Some(Message::ToolResult(ToolResult { - tool_name, - summary, - })) + Some(Message::ToolResult(ToolResult { tool_name, summary })) } // Skip progress, queue-operation, file-history-snapshot for UI _ => None, @@ -80,20 +69,6 @@ pub fn load_session_messages( messages } -/// Extract the value of a named tag from a note. -fn get_tag_value<'a>(note: &'a nostrdb::Note<'a>, tag_name: &str) -> Option<String> { - for tag in note.tags() { - if tag.count() >= 2 { - if let Some(name) = tag.get_str(0) { - if name == tag_name { - return tag.get_str(1).map(|s| s.to_string()); - } - } - } - } - None -} - fn truncate(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { s.to_string() diff --git a/crates/notedeck_dave/src/session_reconstructor.rs b/crates/notedeck_dave/src/session_reconstructor.rs @@ -0,0 +1,86 @@ +//! Reconstruct JSONL from kind-1988 nostr events stored in ndb. +//! +//! Queries events by session ID (`d` tag), sorts by `seq` tag, +//! extracts `source-data` tags, and returns the original JSONL lines. + +use crate::session_events::{get_tag_value, AI_CONVERSATION_KIND}; +use nostrdb::{Filter, Ndb, Transaction}; + +#[derive(Debug)] +pub enum ReconstructError { + Query(String), + Io(String), +} + +impl std::fmt::Display for ReconstructError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReconstructError::Query(e) => write!(f, "ndb query failed: {}", e), + ReconstructError::Io(e) => write!(f, "io error: {}", e), + } + } +} + +/// Reconstruct JSONL lines from ndb events for a given session ID. +/// +/// Returns lines in original order (sorted by `seq` tag), suitable for +/// writing to a JSONL file or feeding to `claude --resume`. +pub fn reconstruct_jsonl_lines( + ndb: &Ndb, + txn: &Transaction, + session_id: &str, +) -> Result<Vec<String>, ReconstructError> { + let filters = [Filter::new() + .kinds([AI_CONVERSATION_KIND as u64]) + .tags([session_id], 'd') + .limit(10000) + .build()]; + + // Use ndb.fold to iterate events without collecting QueryResults + let mut entries: Vec<(u32, String)> = Vec::new(); + + let _ = ndb.fold(txn, &filters, &mut entries, |entries, note| { + let seq = get_tag_value(&note, "seq").and_then(|s| s.parse::<u32>().ok()); + let source_data = get_tag_value(&note, "source-data"); + + // Only events with source-data contribute JSONL lines. + // Split events only have source-data on the first event (i=0), + // so we naturally get one JSONL line per original JSONL line. + if let (Some(seq), Some(data)) = (seq, source_data) { + entries.push((seq, data.to_string())); + } + + entries + }); + + // Sort by seq for original ordering + entries.sort_by_key(|(seq, _)| *seq); + + // Deduplicate by source-data content (safety net for re-ingestion) + entries.dedup_by(|a, b| a.1 == b.1); + + Ok(entries.into_iter().map(|(_, data)| data).collect()) +} + +/// Reconstruct JSONL and write to a file. +/// +/// Returns the number of lines written. +pub fn reconstruct_jsonl_file( + ndb: &Ndb, + txn: &Transaction, + session_id: &str, + output_path: &std::path::Path, +) -> Result<usize, ReconstructError> { + let lines = reconstruct_jsonl_lines(ndb, txn, session_id)?; + let count = lines.len(); + + use std::io::Write; + let mut file = + std::fs::File::create(output_path).map_err(|e| ReconstructError::Io(e.to_string()))?; + + for line in &lines { + writeln!(file, "{}", line).map_err(|e| ReconstructError::Io(e.to_string()))?; + } + + Ok(count) +}