notedeck

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

commit cb39bcb0e95e30b7b08d3dd4c18e9bbdf26b0cbc
parent c417c785f7835e9ff04e44b7f1842e88719a0eab
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 15 Feb 2026 23:40:34 -0800

session_events: generate kind-1988 events in real-time during live conversations

Refactor build_single_event to accept optional JsonlLine, allowing reuse
for both archive (JSONL) and live paths. Add build_live_event() for
generating 1988 events directly from role + content strings.

Wire into process_events() for assistant (on finalize), permission
requests, tool results, and errors. Wire into handle_user_send() for
user messages. No 1989 source-data events for live — archive only.

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_dave/src/session.rs | 14++++++++++++++
Mcrates/notedeck_dave/src/session_events.rs | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
3 files changed, 200 insertions(+), 15 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -123,6 +123,50 @@ struct PendingMessageLoad { claude_session_id: String, } +/// Build and ingest a live kind-1988 event into ndb. +/// +/// Extracts cwd and session ID from the session's agentic data, +/// builds the event, and ingests it. Logs errors without propagating. +fn ingest_live_event( + session: &mut ChatSession, + ndb: &nostrdb::Ndb, + secret_key: &[u8; 32], + content: &str, + role: &str, + tool_id: Option<&str>, +) { + let Some(agentic) = &mut session.agentic else { + return; + }; + + let Some(session_id) = agentic.event_session_id().map(|s| s.to_string()) else { + return; + }; + + let cwd = agentic.cwd.to_str(); + + match session_events::build_live_event( + content, + role, + &session_id, + cwd, + tool_id, + &mut agentic.live_threading, + secret_key, + ) { + Ok(event) => { + if let Err(e) = ndb + .process_event_with(&event.json, nostrdb::IngestMetadata::new().client(true)) + { + tracing::warn!("failed to ingest live event: {:?}", e); + } + } + Err(e) => { + tracing::warn!("failed to build live event: {}", e); + } + } +} + /// Calculate an anonymous user_id from a keypair fn calculate_user_id(keypair: KeypairUnowned) -> String { use sha2::{Digest, Sha256}; @@ -256,6 +300,18 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let mut needs_send: HashSet<SessionId> = HashSet::new(); let active_id = self.session_manager.active_id(); + // Extract secret key once for live event generation + let secret_key: Option<[u8; 32]> = app_ctx + .accounts + .get_selected_account() + .keypair() + .secret_key + .map(|sk| { + sk.as_secret_bytes() + .try_into() + .expect("secret key is 32 bytes") + }); + // Get all session IDs to process let session_ids = self.session_manager.session_ids(); @@ -285,7 +341,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }; match res { - DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)), + DaveApiResponse::Failed(ref err) => { + if let Some(sk) = &secret_key { + ingest_live_event(session, app_ctx.ndb, sk, err, "error", None); + } + session.chat.push(Message::Error(err.to_string())); + } DaveApiResponse::Token(token) => match session.chat.last_mut() { Some(Message::Assistant(msg)) => msg.push_token(&token), @@ -343,6 +404,22 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr pending.request.tool_input ); + // Generate live event for permission request + if let Some(sk) = &secret_key { + let content = format!( + "Permission request: {}", + pending.request.tool_name + ); + ingest_live_event( + session, + app_ctx.ndb, + sk, + &content, + "permission_request", + None, + ); + } + // Store the response sender for later (agentic only) if let Some(agentic) = &mut session.agentic { agentic @@ -358,6 +435,21 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr DaveApiResponse::ToolResult(result) => { tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary); + + // Generate live event for tool result + if let Some(sk) = &secret_key { + let content = + format!("{}: {}", result.tool_name, result.summary); + ingest_live_event( + session, + app_ctx.ndb, + sk, + &content, + "tool_result", + None, + ); + } + // Invalidate git status after file-modifying tools. // tool_name is a String from the Claude SDK, no enum available. if matches!(result.tool_name.as_str(), "Bash" | "Write" | "Edit") { @@ -435,6 +527,24 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(Message::Assistant(msg)) = session.chat.last_mut() { msg.finalize(); } + + // Generate live event for the finalized assistant message + if let Some(sk) = &secret_key { + if let Some(Message::Assistant(msg)) = session.chat.last() { + let text = msg.text().to_string(); + if !text.is_empty() { + ingest_live_event( + session, + app_ctx.ndb, + sk, + &text, + "assistant", + None, + ); + } + } + } + session.task_handle = None; // Don't restore incoming_tokens - leave it None } @@ -849,8 +959,22 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Normal message handling if let Some(session) = self.session_manager.get_active_mut() { - session.chat.push(Message::User(session.input.clone())); + let user_text = session.input.clone(); session.input.clear(); + + // Generate live event for user message + if let Some(sk) = app_ctx + .accounts + .get_selected_account() + .keypair() + .secret_key + { + let sb = sk.as_secret_bytes(); + let secret_bytes: [u8; 32] = sb.try_into().expect("secret key is 32 bytes"); + ingest_live_event(session, app_ctx.ndb, &secret_bytes, &user_text, "user", None); + } + + session.chat.push(Message::User(user_text)); session.update_title_from_last_message(); } self.send_user_message(app_ctx, ui.ctx()); diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -8,6 +8,7 @@ use crate::git_status::GitStatusCache; use crate::messages::{ CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentStatus, }; +use crate::session_events::ThreadingState; use crate::{DaveApiResponse, Message}; use claude_agent_sdk_rs::PermissionMode; use tokio::sync::oneshot; @@ -55,6 +56,8 @@ pub struct AgenticSessionData { pub resume_session_id: Option<String>, /// Git status cache for this session's working directory pub git_status: GitStatusCache, + /// Threading state for live kind-1988 event generation. + pub live_threading: ThreadingState, } impl AgenticSessionData { @@ -81,9 +84,20 @@ impl AgenticSessionData { last_compaction: None, resume_session_id: None, git_status, + live_threading: ThreadingState::new(), } } + /// Get the session ID to use for live kind-1988 events. + /// + /// Prefers claude_session_id from SessionInfo, falls back to resume_session_id. + pub fn event_session_id(&self) -> Option<&str> { + self.session_info + .as_ref() + .and_then(|i| i.claude_session_id.as_deref()) + .or(self.resume_session_id.as_deref()) + } + /// Update a subagent's output (appending new content, keeping only the tail) pub fn update_subagent_output( &mut self, diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -166,12 +166,14 @@ pub fn build_events( }; let event = build_single_event( - line, + Some(line), &content, role, + "claude-code", Some((i, total)), tool_id, session_id.as_deref(), + None, timestamp, threading, secret_key, @@ -200,12 +202,14 @@ pub fn build_events( }); let event = build_single_event( - line, + Some(line), &content, role, + "claude-code", None, tool_id.as_deref(), session_id.as_deref(), + None, timestamp, threading, secret_key, @@ -308,19 +312,25 @@ fn build_source_data_event( }) } -/// Build a single nostr event from a JSONL line. +/// Build a single kind-1988 nostr event. +/// +/// When `line` is provided (archive path), extracts slug, version, model, +/// line_type, and cwd from the JSONL line. When `None` (live path), only +/// uses the explicitly passed parameters. /// /// `split_index`: `Some((i, total))` when this event is part of a split /// assistant message. /// /// `tool_id`: The tool use/result ID for tool_call and tool_result events. fn build_single_event( - line: &JsonlLine, + line: Option<&JsonlLine>, content: &str, role: &str, + source: &str, split_index: Option<(usize, usize)>, tool_id: Option<&str>, session_id: Option<&str>, + cwd: Option<&str>, timestamp: Option<u64>, threading: &ThreadingState, secret_key: &[u8; 32], @@ -338,7 +348,7 @@ fn build_single_event( if let Some(session_id) = session_id { builder = builder.start_tag().tag_str("d").tag_str(session_id); } - if let Some(slug) = line.slug() { + if let Some(slug) = line.and_then(|l| l.slug()) { builder = builder.start_tag().tag_str("session-slug").tag_str(slug); } @@ -365,9 +375,9 @@ fn build_single_event( 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"); + builder = builder.start_tag().tag_str("source").tag_str(source); - if let Some(version) = line.version() { + if let Some(version) = line.and_then(|l| l.version()) { builder = builder .start_tag() .tag_str("source-version") @@ -377,18 +387,17 @@ fn build_single_event( builder = builder.start_tag().tag_str("role").tag_str(role); // Model tag (for assistant messages) - if let Some(msg) = line.message() { - if let Some(model) = msg.model() { - builder = builder.start_tag().tag_str("model").tag_str(model); - } + if let Some(model) = line.and_then(|l| l.message()).and_then(|m| m.model()) { + builder = builder.start_tag().tag_str("model").tag_str(model); } - if let Some(line_type) = line.line_type() { + if let Some(line_type) = line.and_then(|l| l.line_type()) { builder = builder.start_tag().tag_str("turn-type").tag_str(line_type); } // -- CWD tag -- - if let Some(cwd) = line.cwd() { + let resolved_cwd = cwd.or_else(|| line.and_then(|l| l.cwd())); + if let Some(cwd) = resolved_cwd { builder = builder.start_tag().tag_str("cwd").tag_str(cwd); } @@ -428,6 +437,44 @@ fn build_single_event( }) } +/// Build a kind-1988 event for a live conversation message. +/// +/// Unlike `build_events()` which works from JSONL lines, this builds directly +/// from role + content strings. No kind-1989 source-data events are created. +/// +/// Calls `threading.record()` internally. +pub fn build_live_event( + content: &str, + role: &str, + session_id: &str, + cwd: Option<&str>, + tool_id: Option<&str>, + threading: &mut ThreadingState, + secret_key: &[u8; 32], +) -> Result<BuiltEvent, EventBuildError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let event = build_single_event( + None, + content, + role, + "notedeck-dave", + None, + tool_id, + Some(session_id), + cwd, + Some(now), + threading, + secret_key, + )?; + + threading.record(None, event.note_id); + Ok(event) +} + #[cfg(test)] mod tests { use super::*;