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:
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::*;