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:
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(¬e, "seq").and_then(|s| s.parse::<u32>().ok());
+ let source_data = get_tag_value(¬e, "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)
+}